mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 04:17:23 +00:00
format
This commit is contained in:
@@ -24,31 +24,31 @@ export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
import reactX from "eslint-plugin-react-x";
|
||||
import reactDom from "eslint-plugin-react-dom";
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
"react-x": reactX,
|
||||
"react-dom": reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactX.configs["recommended-typescript"].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
@@ -1,33 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<base href="/">
|
||||
<base href="/" />
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="{{Description}}">
|
||||
<meta charset="UTF-8" />
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
|
||||
<meta name="description" content="{{Description}}" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- SEO meta -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{Title}}">
|
||||
<meta name="twitter:description" content="{{Description}}">
|
||||
<meta name="twitter:image" content="{{Preview}}">
|
||||
<meta property="og:title" content="{{Title}}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{Url}}">
|
||||
<meta property="og:image" content="{{Preview}}">
|
||||
<meta property="og:description" content="{{Description}}">
|
||||
<meta property="og:site_name" content={{SiteName}}>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{{Title}}" />
|
||||
<meta name="twitter:description" content="{{Description}}" />
|
||||
<meta name="twitter:image" content="{{Preview}}" />
|
||||
<meta property="og:title" content="{{Title}}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{{Url}}" />
|
||||
<meta property="og:image" content="{{Preview}}" />
|
||||
<meta property="og:description" content="{{Description}}" />
|
||||
<meta property="og:site_name" content="{{SiteName}}" />
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="Nysoure">
|
||||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="Nysoure" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
|
||||
<title>{{Title}}</title>
|
||||
</head>
|
||||
|
@@ -7,7 +7,7 @@ interface MyWindow extends Window {
|
||||
}
|
||||
|
||||
class App {
|
||||
appName = "Nysoure"
|
||||
appName = "Nysoure";
|
||||
|
||||
user: User | null = null;
|
||||
|
||||
@@ -15,7 +15,7 @@ class App {
|
||||
|
||||
cloudflareTurnstileSiteKey: string | null = null;
|
||||
|
||||
siteInfo = ""
|
||||
siteInfo = "";
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
@@ -31,7 +31,8 @@ class App {
|
||||
this.token = JSON.parse(tokenJson);
|
||||
}
|
||||
this.appName = (window as MyWindow).serverName || this.appName;
|
||||
this.cloudflareTurnstileSiteKey = (window as MyWindow).cloudflareTurnstileSiteKey || null;
|
||||
this.cloudflareTurnstileSiteKey =
|
||||
(window as MyWindow).cloudflareTurnstileSiteKey || null;
|
||||
this.siteInfo = (window as MyWindow).siteInfo || "";
|
||||
}
|
||||
|
||||
|
@@ -33,5 +33,5 @@ export default function App() {
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@@ -1,10 +1,6 @@
|
||||
import { createContext, ReactNode, useContext } from "react";
|
||||
|
||||
export default function AppContext({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
export default function AppContext({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<context.Provider value={new Map<string, any>()}>
|
||||
{children}
|
||||
@@ -15,5 +11,5 @@ export default function AppContext({
|
||||
const context = createContext<Map<string, any>>(new Map<string, any>());
|
||||
|
||||
export function useAppContext() {
|
||||
return useContext(context)
|
||||
return useContext(context);
|
||||
}
|
@@ -1,18 +1,53 @@
|
||||
export function ErrorAlert({ message, className }: { message: string, className?: string }) {
|
||||
return <div role="alert" className={`alert alert-error ${className}`}>
|
||||
<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" />
|
||||
export function ErrorAlert({
|
||||
message,
|
||||
className,
|
||||
}: {
|
||||
message: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="alert" className={`alert alert-error ${className}`}>
|
||||
<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>{message}</span>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoAlert({ message, className }: { message: string, className?: string }) {
|
||||
return <div role="alert" className={`alert alert-info ${className}`}>
|
||||
<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>
|
||||
export function InfoAlert({
|
||||
message,
|
||||
className,
|
||||
}: {
|
||||
message: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="alert" className={`alert alert-info ${className}`}>
|
||||
<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>{message}</span>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,9 +1,39 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function Badge({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) {
|
||||
return <span className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`} onClick={onClick}>{children}</span>
|
||||
export default function Badge({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BadgeAccent({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) {
|
||||
return <span className={`badge badge-accent text-sm ${className}`} onClick={onClick}>{children}</span>
|
||||
export function BadgeAccent({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`badge badge-accent text-sm ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
@@ -1,14 +1,28 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function Button({ children, onClick, className, disabled, isLoading }: { children: ReactNode, onClick?: () => void, className?: string, disabled?: boolean, isLoading?: boolean }) {
|
||||
return <button
|
||||
export default function Button({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
disabled,
|
||||
isLoading,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`btn ${className} ${disabled ? "btn-disabled" : ""} h-9`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isLoading && <span className="loading loading-spinner loading-sm mr-2"></span>}
|
||||
<span className="text-sm">
|
||||
{children}
|
||||
</span>
|
||||
</button>;
|
||||
{isLoading && (
|
||||
<span className="loading loading-spinner loading-sm mr-2"></span>
|
||||
)}
|
||||
<span className="text-sm">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
@@ -15,65 +15,83 @@ async function uploadImages(files: File[]): Promise<number[]> {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: `Failed to upload image: ${res.message}`,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
export function SelectAndUploadImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) {
|
||||
const [isUploading, setUploading] = useState(false)
|
||||
export function SelectAndUploadImageButton({
|
||||
onUploaded,
|
||||
}: {
|
||||
onUploaded: (image: number[]) => void;
|
||||
}) {
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const addImage = () => {
|
||||
if (isUploading) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "image/*"
|
||||
input.multiple = true
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (!input.files || input.files.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
setUploading(true)
|
||||
setUploading(true);
|
||||
const files = Array.from(input.files);
|
||||
const uploadedImages = await uploadImages(files);
|
||||
setUploading(false);
|
||||
if (uploadedImages.length > 0) {
|
||||
onUploaded(uploadedImages);
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
return <button className={"btn my-2"} type={"button"} onClick={addImage}>
|
||||
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
||||
return (
|
||||
<button className={"btn my-2"} type={"button"} onClick={addImage}>
|
||||
{isUploading ? (
|
||||
<span className="loading loading-spinner"></span>
|
||||
) : (
|
||||
<MdAdd />
|
||||
)}
|
||||
{t("Upload Image")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadClipboardImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) {
|
||||
const [isUploading, setUploading] = useState(false)
|
||||
export function UploadClipboardImageButton({
|
||||
onUploaded,
|
||||
}: {
|
||||
onUploaded: (image: number[]) => void;
|
||||
}) {
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const addClipboardImage = async () => {
|
||||
if (isUploading) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
const files: File[] = [];
|
||||
for (const item of clipboardItems) {
|
||||
console.log(item)
|
||||
console.log(item);
|
||||
for (const type of item.types) {
|
||||
if (type.startsWith("image/")) {
|
||||
const blob = await item.getType(type);
|
||||
files.push(new File([blob], `clipboard-image.${type.split("/")[1]}`, { type }));
|
||||
files.push(
|
||||
new File([blob], `clipboard-image.${type.split("/")[1]}`, {
|
||||
type,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,15 +114,27 @@ export function UploadClipboardImageButton({onUploaded}: {onUploaded: (image: nu
|
||||
message: t("Failed to read clipboard image"),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return <button className={"btn my-2"} type={"button"} onClick={addClipboardImage}>
|
||||
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
||||
return (
|
||||
<button className={"btn my-2"} type={"button"} onClick={addClipboardImage}>
|
||||
{isUploading ? (
|
||||
<span className="loading loading-spinner"></span>
|
||||
) : (
|
||||
<MdAdd />
|
||||
)}
|
||||
{t("Upload Clipboard Image")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode, onUploaded: (image: number[]) => void}) {
|
||||
export function ImageDrapArea({
|
||||
children,
|
||||
onUploaded,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onUploaded: (image: number[]) => void;
|
||||
}) {
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -128,7 +158,7 @@ export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
setUploading(true);
|
||||
let files = Array.from(e.dataTransfer.files);
|
||||
files = files.filter(file => file.type.startsWith("image/"));
|
||||
files = files.filter((file) => file.type.startsWith("image/"));
|
||||
if (files.length === 0) {
|
||||
setUploading(false);
|
||||
return;
|
||||
|
@@ -11,15 +11,31 @@ interface InputProps {
|
||||
|
||||
export default function Input(props: InputProps) {
|
||||
if (props.inlineLabel) {
|
||||
return <label className="input w-full">
|
||||
return (
|
||||
<label className="input w-full">
|
||||
{props.label}
|
||||
<input type={props.type} className="grow" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
|
||||
<input
|
||||
type={props.type}
|
||||
className="grow"
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
} else {
|
||||
return <fieldset className="fieldset w-full">
|
||||
return (
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{props.label}</legend>
|
||||
<input type={props.type} className="input w-full" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
|
||||
<input
|
||||
type={props.type}
|
||||
className="input w-full"
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +47,17 @@ interface TextAreaProps {
|
||||
}
|
||||
|
||||
export function TextArea(props: TextAreaProps) {
|
||||
return <fieldset className="fieldset w-full">
|
||||
return (
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{props.label}</legend>
|
||||
<textarea className={`textarea w-full ${props.height != undefined ? "resize-none" : ""}`} value={props.value} onChange={props.onChange} style={{
|
||||
<textarea
|
||||
className={`textarea w-full ${props.height != undefined ? "resize-none" : ""}`}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
style={{
|
||||
height: props.height,
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
@@ -3,8 +3,10 @@ import { useTranslation } from "react-i18next";
|
||||
export default function Loading() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <div className={"flex justify-center py-4"}>
|
||||
return (
|
||||
<div className={"flex justify-center py-4"}>
|
||||
<span className="loading loading-spinner progress-primary loading-lg mr-2"></span>
|
||||
<span>{t("Loading")}</span>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -2,16 +2,21 @@ import { app } from "../app.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import { useNavigate, useOutlet } from "react-router";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import {MdArrowUpward, MdOutlinePerson, MdSearch, MdSettings} from "react-icons/md";
|
||||
import {
|
||||
MdArrowUpward,
|
||||
MdOutlinePerson,
|
||||
MdSearch,
|
||||
MdSettings,
|
||||
} from "react-icons/md";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UploadingSideBar from "./uploading_side_bar.tsx";
|
||||
import { IoLogoGithub } from "react-icons/io";
|
||||
import { useAppContext } from "./AppContext.tsx";
|
||||
|
||||
export default function Navigator() {
|
||||
const outlet = useOutlet()
|
||||
const outlet = useOutlet();
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
@@ -25,86 +30,154 @@ export default function Navigator() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <>
|
||||
return (
|
||||
<>
|
||||
<FloatingToTopButton />
|
||||
<div className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10" key={key}>
|
||||
<div
|
||||
className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10"
|
||||
key={key}
|
||||
>
|
||||
<div className={"flex-1 max-w-7xl mx-auto flex items-center"}>
|
||||
<div className="dropdown">
|
||||
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle lg:hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" /> </svg>
|
||||
</div>
|
||||
<ul id={"navi_menu"}
|
||||
<div
|
||||
tabIndex={0}
|
||||
className="menu menu-md dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
|
||||
<li onClick={() => {
|
||||
const menu = document.getElementById("navi_menu") as HTMLElement;
|
||||
role="button"
|
||||
className="btn btn-ghost btn-circle lg:hidden"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{" "}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h7"
|
||||
/>{" "}
|
||||
</svg>
|
||||
</div>
|
||||
<ul
|
||||
id={"navi_menu"}
|
||||
tabIndex={0}
|
||||
className="menu menu-md dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
|
||||
>
|
||||
<li
|
||||
onClick={() => {
|
||||
const menu = document.getElementById(
|
||||
"navi_menu",
|
||||
) as HTMLElement;
|
||||
menu.blur();
|
||||
navigate("/");
|
||||
}}><a>{t("Home")}</a></li>
|
||||
<li onClick={() => {
|
||||
const menu = document.getElementById("navi_menu") as HTMLElement;
|
||||
}}
|
||||
>
|
||||
<a>{t("Home")}</a>
|
||||
</li>
|
||||
<li
|
||||
onClick={() => {
|
||||
const menu = document.getElementById(
|
||||
"navi_menu",
|
||||
) as HTMLElement;
|
||||
menu.blur();
|
||||
navigate("/tags")
|
||||
}}><a>{t("Tags")}</a></li>
|
||||
<li onClick={() => {
|
||||
const menu = document.getElementById("navi_menu") as HTMLElement;
|
||||
navigate("/tags");
|
||||
}}
|
||||
>
|
||||
<a>{t("Tags")}</a>
|
||||
</li>
|
||||
<li
|
||||
onClick={() => {
|
||||
const menu = document.getElementById(
|
||||
"navi_menu",
|
||||
) as HTMLElement;
|
||||
menu.blur();
|
||||
navigate("/about")
|
||||
}}><a>{t("About")}</a></li>
|
||||
navigate("/about");
|
||||
}}
|
||||
>
|
||||
<a>{t("About")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn btn-ghost text-xl" onClick={() => {
|
||||
appContext.clear()
|
||||
<button
|
||||
className="btn btn-ghost text-xl"
|
||||
onClick={() => {
|
||||
appContext.clear();
|
||||
navigate(`/`, { replace: true });
|
||||
}}>{app.appName}</button>
|
||||
}}
|
||||
>
|
||||
{app.appName}
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden lg:flex">
|
||||
<ul className="menu menu-horizontal px-1">
|
||||
<li onClick={() => {
|
||||
<li
|
||||
onClick={() => {
|
||||
navigate("/");
|
||||
}}><a>{t("Home")}</a></li>
|
||||
<li onClick={() => {
|
||||
navigate("/tags")
|
||||
}}><a>{t("Tags")}</a></li>
|
||||
<li onClick={() => {
|
||||
navigate("/about")
|
||||
}}><a>{t("About")}</a></li>
|
||||
}}
|
||||
>
|
||||
<a>{t("Home")}</a>
|
||||
</li>
|
||||
<li
|
||||
onClick={() => {
|
||||
navigate("/tags");
|
||||
}}
|
||||
>
|
||||
<a>{t("Tags")}</a>
|
||||
</li>
|
||||
<li
|
||||
onClick={() => {
|
||||
navigate("/about");
|
||||
}}
|
||||
>
|
||||
<a>{t("About")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={"flex-1"}></div>
|
||||
<div className="flex gap-2">
|
||||
<SearchBar />
|
||||
<UploadingSideBar />
|
||||
{
|
||||
app.isLoggedIn() && <button className={"btn btn-circle btn-ghost"} onClick={() => {
|
||||
{app.isLoggedIn() && (
|
||||
<button
|
||||
className={"btn btn-circle btn-ghost"}
|
||||
onClick={() => {
|
||||
navigate("/manage");
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<MdSettings size={24} />
|
||||
</button>
|
||||
}
|
||||
<button className={"btn btn-circle btn-ghost"} onClick={() => {
|
||||
)}
|
||||
<button
|
||||
className={"btn btn-circle btn-ghost"}
|
||||
onClick={() => {
|
||||
window.open("https://github.com/wgh136/nysoure", "_blank");
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<IoLogoGithub size={24} />
|
||||
</button>
|
||||
{
|
||||
app.isLoggedIn() ? <UserButton/> :
|
||||
<button className={"btn btn-primary btn-square btn-soft"} onClick={() => {
|
||||
{app.isLoggedIn() ? (
|
||||
<UserButton />
|
||||
) : (
|
||||
<button
|
||||
className={"btn btn-primary btn-square btn-soft"}
|
||||
onClick={() => {
|
||||
navigate("/login");
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<MdOutlinePerson size={24}></MdOutlinePerson>
|
||||
</button>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<navigatorContext.Provider value={naviContext}>
|
||||
<div className={"max-w-7xl mx-auto pt-16"}>
|
||||
{outlet}
|
||||
</div>
|
||||
<div className={"max-w-7xl mx-auto pt-16"}>{outlet}</div>
|
||||
</navigatorContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavigatorContext {
|
||||
@@ -114,8 +187,8 @@ interface NavigatorContext {
|
||||
const navigatorContext = createContext<NavigatorContext>({
|
||||
refresh: () => {
|
||||
// do nothing
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
export function useNavigator() {
|
||||
return useContext(navigatorContext);
|
||||
@@ -124,40 +197,68 @@ export function useNavigator() {
|
||||
function UserButton() {
|
||||
let avatar = "./avatar.png";
|
||||
if (app.user) {
|
||||
avatar = network.getUserAvatar(app.user)
|
||||
avatar = network.getUserAvatar(app.user);
|
||||
}
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <>
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown dropdown-end">
|
||||
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle avatar">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-ghost btn-circle avatar"
|
||||
>
|
||||
<div className="w-10 rounded-full">
|
||||
<img
|
||||
alt="Avatar"
|
||||
src={avatar} />
|
||||
<img alt="Avatar" src={avatar} />
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
id={"navi_dropdown_menu"}
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
|
||||
<li><a onClick={() => {
|
||||
className="menu dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
onClick={() => {
|
||||
navigate(`/user/${app.user?.username}`);
|
||||
const menu = document.getElementById("navi_dropdown_menu") as HTMLUListElement;
|
||||
const menu = document.getElementById(
|
||||
"navi_dropdown_menu",
|
||||
) as HTMLUListElement;
|
||||
menu.blur();
|
||||
}}>{t("My Profile")}</a></li>
|
||||
<li><a onClick={() => {
|
||||
}}
|
||||
>
|
||||
{t("My Profile")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={() => {
|
||||
navigate(`/publish`);
|
||||
const menu = document.getElementById("navi_dropdown_menu") as HTMLUListElement;
|
||||
const menu = document.getElementById(
|
||||
"navi_dropdown_menu",
|
||||
) as HTMLUListElement;
|
||||
menu.blur();
|
||||
}}>{t("Publish")}</a></li>
|
||||
<li><a onClick={() => {
|
||||
const dialog = document.getElementById("confirm_logout") as HTMLDialogElement;
|
||||
}}
|
||||
>
|
||||
{t("Publish")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"confirm_logout",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>{t("Log out")}</a></li>
|
||||
}}
|
||||
>
|
||||
{t("Log out")}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<dialog id="confirm_logout" className="modal">
|
||||
@@ -166,19 +267,25 @@ function UserButton() {
|
||||
<p className="py-4">{t("Are you sure you want to log out?")}</p>
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<button className="btn">{t('Cancel')}</button>
|
||||
<button className="btn btn-error mx-2" type={"button"} onClick={() => {
|
||||
<button className="btn">{t("Cancel")}</button>
|
||||
<button
|
||||
className="btn btn-error mx-2"
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
app.user = null;
|
||||
app.token = null;
|
||||
app.saveData();
|
||||
navigate(`/login`, { replace: true });
|
||||
}}>{t('Confirm')}
|
||||
}}
|
||||
>
|
||||
{t("Confirm")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBar() {
|
||||
@@ -212,10 +319,15 @@ function SearchBar() {
|
||||
}
|
||||
const replace = window.location.pathname === "/search";
|
||||
navigate(`/search?keyword=${search}`, { replace: replace });
|
||||
}
|
||||
};
|
||||
|
||||
const searchField = <label className={`input input-primary ${small ? "w-full" : "w-64"}`}>
|
||||
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
const searchField = (
|
||||
<label className={`input input-primary ${small ? "w-full" : "w-64"}`}>
|
||||
<svg
|
||||
className="h-[1em] opacity-50"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
@@ -227,49 +339,74 @@ function SearchBar() {
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<form className={"w-full"} onSubmit={(e) => {
|
||||
<form
|
||||
className={"w-full"}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSearch();
|
||||
}}>
|
||||
<input type="search" className={"w-full"} required placeholder={t("Search")} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
className={"w-full"}
|
||||
required
|
||||
placeholder={t("Search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</label>
|
||||
);
|
||||
|
||||
if (small) {
|
||||
return <>
|
||||
<button className={"btn btn-circle btn-ghost"} onClick={() => {
|
||||
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={"btn btn-circle btn-ghost"}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"search_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<MdSearch size={24} />
|
||||
</button>
|
||||
<dialog id="search_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<form method="dialog">
|
||||
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
<h3 className="text-lg font-bold">{t("Search")}</h3>
|
||||
<div className={"h-4"} />
|
||||
{searchField}
|
||||
<div className={"h-4"} />
|
||||
<div className={"flex flex-row-reverse"}>
|
||||
<button className={"btn btn-primary"} onClick={() => {
|
||||
<button
|
||||
className={"btn btn-primary"}
|
||||
onClick={() => {
|
||||
if (search.length === 0) {
|
||||
return;
|
||||
}
|
||||
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
|
||||
const dialog = document.getElementById(
|
||||
"search_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
doSearch();
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{t("Search")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return searchField
|
||||
return searchField;
|
||||
}
|
||||
|
||||
function FloatingToTopButton() {
|
||||
@@ -293,9 +430,14 @@ function FloatingToTopButton() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <button className={`btn btn-circle btn-soft btn-secondary border shadow-lg btn-lg fixed right-4 ${visible ? "bottom-4" : "-bottom-12"} transition-all z-50`} onClick={() => {
|
||||
return (
|
||||
<button
|
||||
className={`btn btn-circle btn-soft btn-secondary border shadow-lg btn-lg fixed right-4 ${visible ? "bottom-4" : "-bottom-12"} transition-all z-50`}
|
||||
onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<MdArrowUpward size={20} />
|
||||
</button>;
|
||||
</button>
|
||||
);
|
||||
}
|
@@ -1,48 +1,106 @@
|
||||
import { ReactNode } from "react";
|
||||
import { MdChevronLeft, MdChevronRight } from "react-icons/md";
|
||||
|
||||
export default function Pagination({page, setPage, totalPages}: {
|
||||
page: number,
|
||||
setPage: (page: number) => void,
|
||||
totalPages: number
|
||||
export default function Pagination({
|
||||
page,
|
||||
setPage,
|
||||
totalPages,
|
||||
}: {
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
totalPages: number;
|
||||
}) {
|
||||
const items: ReactNode[] = [];
|
||||
|
||||
if (page > 1) {
|
||||
items.push(<button key={"btn-1"} className="join-item btn" onClick={() => setPage(1)}>1</button>);
|
||||
items.push(
|
||||
<button
|
||||
key={"btn-1"}
|
||||
className="join-item btn"
|
||||
onClick={() => setPage(1)}
|
||||
>
|
||||
1
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
if (page - 2 > 1) {
|
||||
items.push(<button key={"btn-2"} className="join-item btn">...</button>);
|
||||
items.push(
|
||||
<button key={"btn-2"} className="join-item btn">
|
||||
...
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
if (page - 1 > 1) {
|
||||
items.push(<button key={"btn-3"} className="join-item btn" onClick={() => setPage(page - 1)}>{page - 1}</button>);
|
||||
items.push(
|
||||
<button
|
||||
key={"btn-3"}
|
||||
className="join-item btn"
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
{page - 1}
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
items.push(<button key={"btn-4"} className="join-item btn btn-active">{page}</button>);
|
||||
items.push(
|
||||
<button key={"btn-4"} className="join-item btn btn-active">
|
||||
{page}
|
||||
</button>,
|
||||
);
|
||||
if (page + 1 < totalPages) {
|
||||
items.push(<button key={"btn-5"} className="join-item btn" onClick={() => setPage(page + 1)}>{page + 1}</button>);
|
||||
items.push(
|
||||
<button
|
||||
key={"btn-5"}
|
||||
className="join-item btn"
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{page + 1}
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
if (page + 2 < totalPages) {
|
||||
items.push(<button key={"btn-6"} className="join-item btn">...</button>);
|
||||
items.push(
|
||||
<button key={"btn-6"} className="join-item btn">
|
||||
...
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
if (page < totalPages) {
|
||||
items.push(<button key={"btn-7"} className="join-item btn" onClick={() => setPage(totalPages)}>{totalPages}</button>);
|
||||
items.push(
|
||||
<button
|
||||
key={"btn-7"}
|
||||
className="join-item btn"
|
||||
onClick={() => setPage(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="join shadow rounded-field">
|
||||
<button key={"btn-prev"} className={`join-item btn`} onClick={() => {
|
||||
return (
|
||||
<div className="join shadow rounded-field">
|
||||
<button
|
||||
key={"btn-prev"}
|
||||
className={`join-item btn`}
|
||||
onClick={() => {
|
||||
if (page > 1) {
|
||||
setPage(page - 1);
|
||||
}
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<MdChevronLeft size={20} className="opacity-50" />
|
||||
</button>
|
||||
{items}
|
||||
<button key={"btn-next"} className={`join-item btn`} onClick={() => {
|
||||
<button
|
||||
key={"btn-next"}
|
||||
className={`join-item btn`}
|
||||
onClick={() => {
|
||||
if (page < totalPages) {
|
||||
setPage(page + 1);
|
||||
}
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<MdChevronRight size={20} className="opacity-50" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,7 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
export default function showPopup(content: React.ReactNode, element: HTMLElement) {
|
||||
export default function showPopup(
|
||||
content: React.ReactNode,
|
||||
element: HTMLElement,
|
||||
) {
|
||||
const eRect = element.getBoundingClientRect();
|
||||
|
||||
const div = document.createElement("div");
|
||||
@@ -39,9 +42,9 @@ export default function showPopup(content: React.ReactNode, element: HTMLElement
|
||||
mask.onclick = close;
|
||||
document.body.appendChild(mask);
|
||||
|
||||
createRoot(div).render(<context.Provider value={close}>
|
||||
{content}
|
||||
</context.Provider>)
|
||||
createRoot(div).render(
|
||||
<context.Provider value={close}>{content}</context.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
const context = React.createContext<() => void>(() => {});
|
||||
@@ -50,12 +53,22 @@ export function useClosePopup() {
|
||||
return React.useContext(context);
|
||||
}
|
||||
|
||||
export function PopupMenuItem({ children, onClick }: { children: React.ReactNode, onClick: () => void }) {
|
||||
export function PopupMenuItem({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const close = useClosePopup();
|
||||
return <li onClick={() => {
|
||||
return (
|
||||
<li
|
||||
onClick={() => {
|
||||
close();
|
||||
onClick();
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
@@ -4,36 +4,44 @@ import { useNavigate } from "react-router";
|
||||
import Badge from "./badge.tsx";
|
||||
|
||||
export default function ResourceCard({ resource }: { resource: Resource }) {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
let tags = resource.tags
|
||||
let tags = resource.tags;
|
||||
if (tags.length > 10) {
|
||||
tags = tags.slice(0, 10)
|
||||
tags = tags.slice(0, 10);
|
||||
}
|
||||
|
||||
return <div className={"p-2 cursor-pointer"} onClick={() => {
|
||||
navigate(`/resources/${resource.id}`)
|
||||
}}>
|
||||
return (
|
||||
<div
|
||||
className={"p-2 cursor-pointer"}
|
||||
onClick={() => {
|
||||
navigate(`/resources/${resource.id}`);
|
||||
}}
|
||||
>
|
||||
<div className={"card shadow hover:shadow-md transition-shadow"}>
|
||||
{
|
||||
resource.image != null && <figure>
|
||||
{resource.image != null && (
|
||||
<figure>
|
||||
<img
|
||||
src={network.getImageUrl(resource.image.id)}
|
||||
alt="cover" style={{
|
||||
alt="cover"
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: resource.image.width / resource.image.height,
|
||||
}}/>
|
||||
}}
|
||||
/>
|
||||
</figure>
|
||||
}
|
||||
)}
|
||||
<div className="flex flex-col p-4">
|
||||
<h2 className="card-title">{resource.title}</h2>
|
||||
<div className="h-2"></div>
|
||||
<p>
|
||||
{
|
||||
tags.map((tag) => {
|
||||
return <Badge key={tag.id} className={"m-0.5"}>{tag.name}</Badge>
|
||||
})
|
||||
}
|
||||
{tags.map((tag) => {
|
||||
return (
|
||||
<Badge key={tag.id} className={"m-0.5"}>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
<div className="h-2"></div>
|
||||
<div className="flex items-center">
|
||||
@@ -48,4 +56,5 @@ export default function ResourceCard({ resource }: { resource: Resource }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -6,64 +6,75 @@ import {Masonry, useInfiniteLoader} from "masonic";
|
||||
import Loading from "./loading.tsx";
|
||||
import { useAppContext } from "./AppContext.tsx";
|
||||
|
||||
export default function ResourcesView({loader, storageKey}: {loader: (page: number) => Promise<PageResponse<Resource>>, storageKey?: string}) {
|
||||
const [data, setData] = useState<Resource[]>([])
|
||||
const pageRef = useRef(1)
|
||||
const totalPagesRef = useRef(1)
|
||||
const isLoadingRef = useRef(false)
|
||||
export default function ResourcesView({
|
||||
loader,
|
||||
storageKey,
|
||||
}: {
|
||||
loader: (page: number) => Promise<PageResponse<Resource>>;
|
||||
storageKey?: string;
|
||||
}) {
|
||||
const [data, setData] = useState<Resource[]>([]);
|
||||
const pageRef = useRef(1);
|
||||
const totalPagesRef = useRef(1);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
const appContext = useAppContext()
|
||||
const appContext = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey) {
|
||||
const data = appContext.get(storageKey + "/data")
|
||||
const page = appContext.get(storageKey + "/page")
|
||||
const totalPages = appContext.get(storageKey + "/totalPages")
|
||||
console.log("loading data", data, page, totalPages)
|
||||
const data = appContext.get(storageKey + "/data");
|
||||
const page = appContext.get(storageKey + "/page");
|
||||
const totalPages = appContext.get(storageKey + "/totalPages");
|
||||
console.log("loading data", data, page, totalPages);
|
||||
if (data) {
|
||||
setData(data)
|
||||
pageRef.current = page
|
||||
totalPagesRef.current = totalPages
|
||||
setData(data);
|
||||
pageRef.current = page;
|
||||
totalPagesRef.current = totalPages;
|
||||
}
|
||||
}
|
||||
}, [appContext, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey && data.length > 0) {
|
||||
console.log("storing data", data)
|
||||
appContext.set(storageKey + "/data", data)
|
||||
appContext.set(storageKey + "/page", pageRef.current)
|
||||
appContext.set(storageKey + "/totalPages", totalPagesRef.current)
|
||||
console.log("storing data", data);
|
||||
appContext.set(storageKey + "/data", data);
|
||||
appContext.set(storageKey + "/page", pageRef.current);
|
||||
appContext.set(storageKey + "/totalPages", totalPagesRef.current);
|
||||
}
|
||||
}, [appContext, data, storageKey]);
|
||||
|
||||
const loadPage = useCallback(async () => {
|
||||
if (pageRef.current > totalPagesRef.current) return
|
||||
if (isLoadingRef.current) return
|
||||
isLoadingRef.current = true
|
||||
const res = await loader(pageRef.current)
|
||||
if (pageRef.current > totalPagesRef.current) return;
|
||||
if (isLoadingRef.current) return;
|
||||
isLoadingRef.current = true;
|
||||
const res = await loader(pageRef.current);
|
||||
if (!res.success) {
|
||||
showToast({message: res.message, type: "error"})
|
||||
showToast({ message: res.message, type: "error" });
|
||||
} else {
|
||||
isLoadingRef.current = false
|
||||
pageRef.current = pageRef.current + 1
|
||||
totalPagesRef.current = res.totalPages ?? 1
|
||||
setData((prev) => [...prev, ...res.data!])
|
||||
isLoadingRef.current = false;
|
||||
pageRef.current = pageRef.current + 1;
|
||||
totalPagesRef.current = res.totalPages ?? 1;
|
||||
setData((prev) => [...prev, ...res.data!]);
|
||||
}
|
||||
}, [loader])
|
||||
}, [loader]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPage()
|
||||
loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
const maybeLoadMore = useInfiniteLoader(loadPage)
|
||||
const maybeLoadMore = useInfiniteLoader(loadPage);
|
||||
|
||||
return <div className={"px-2 pt-2"}>
|
||||
<Masonry onRender={maybeLoadMore} columnWidth={300} items={data} render={(e) => {
|
||||
return <ResourceCard resource={e.data} key={e.data.id}/>
|
||||
} }></Masonry>
|
||||
{
|
||||
pageRef.current <= totalPagesRef.current && <Loading/>
|
||||
}
|
||||
return (
|
||||
<div className={"px-2 pt-2"}>
|
||||
<Masonry
|
||||
onRender={maybeLoadMore}
|
||||
columnWidth={300}
|
||||
items={data}
|
||||
render={(e) => {
|
||||
return <ResourceCard resource={e.data} key={e.data.id} />;
|
||||
}}
|
||||
></Masonry>
|
||||
{pageRef.current <= totalPagesRef.current && <Loading />}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -8,190 +8,259 @@ import Button from "./button.tsx";
|
||||
import Input, { TextArea } from "./input.tsx";
|
||||
import { ErrorAlert } from "./alert.tsx";
|
||||
|
||||
export default function TagInput({ onAdd, mainTag }: { onAdd: (tag: Tag) => void, mainTag?: boolean }) {
|
||||
const [keyword, setKeyword] = useState<string>("")
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
export default function TagInput({
|
||||
onAdd,
|
||||
mainTag,
|
||||
}: {
|
||||
onAdd: (tag: Tag) => void;
|
||||
mainTag?: boolean;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState<string>("");
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
const debounce = useRef(new Debounce(500))
|
||||
const debounce = useRef(new Debounce(500));
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchTags = async (keyword: string) => {
|
||||
if (keyword.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
setLoading(true)
|
||||
setTags([])
|
||||
setError(null)
|
||||
const res = await network.searchTags(keyword, mainTag)
|
||||
setLoading(true);
|
||||
setTags([]);
|
||||
setError(null);
|
||||
const res = await network.searchTags(keyword, mainTag);
|
||||
if (!res.success) {
|
||||
setError(res.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setTags(res.data!)
|
||||
setLoading(false)
|
||||
setError(res.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setTags(res.data!);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleChange = async (v: string) => {
|
||||
setKeyword(v)
|
||||
setTags([])
|
||||
setError(null)
|
||||
setKeyword(v);
|
||||
setTags([]);
|
||||
setError(null);
|
||||
if (v.length !== 0) {
|
||||
setLoading(true)
|
||||
debounce.current.run(() => searchTags(v))
|
||||
setLoading(true);
|
||||
debounce.current.run(() => searchTags(v));
|
||||
} else {
|
||||
setLoading(false)
|
||||
debounce.current.cancel()
|
||||
}
|
||||
setLoading(false);
|
||||
debounce.current.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTag = async (name: string) => {
|
||||
setLoading(true)
|
||||
const res = await network.createTag(name)
|
||||
setLoading(true);
|
||||
const res = await network.createTag(name);
|
||||
if (!res.success) {
|
||||
setError(res.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
onAdd(res.data!)
|
||||
setKeyword("")
|
||||
setTags([])
|
||||
setLoading(false)
|
||||
const input = document.getElementById("search_tags_input") as HTMLInputElement
|
||||
input.blur()
|
||||
setError(res.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
onAdd(res.data!);
|
||||
setKeyword("");
|
||||
setTags([]);
|
||||
setLoading(false);
|
||||
const input = document.getElementById(
|
||||
"search_tags_input",
|
||||
) as HTMLInputElement;
|
||||
input.blur();
|
||||
};
|
||||
|
||||
let dropdownContent
|
||||
let dropdownContent;
|
||||
if (error) {
|
||||
dropdownContent = <div className="alert alert-error my-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
dropdownContent = (
|
||||
<div className="alert alert-error my-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (!keyword) {
|
||||
dropdownContent = <div className="flex flex-row py-2 px-4">
|
||||
dropdownContent = (
|
||||
<div className="flex flex-row py-2 px-4">
|
||||
<LuInfo size={20} />
|
||||
<span className={"w-2"} />
|
||||
<span className={"flex-1"}>{t("Please enter a search keyword")}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (isLoading) {
|
||||
dropdownContent = <div className="flex flex-row py-2 px-4">
|
||||
dropdownContent = (
|
||||
<div className="flex flex-row py-2 px-4">
|
||||
<span className={"loading loading-spinner loading-sm"}></span>
|
||||
<span className={"w-2"} />
|
||||
<span className={"flex-1"}>{t("Searching...")}</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined
|
||||
dropdownContent = <>
|
||||
{
|
||||
tags.map((t) => {
|
||||
return <li key={t.id} onClick={() => {
|
||||
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined;
|
||||
dropdownContent = (
|
||||
<>
|
||||
{tags.map((t) => {
|
||||
return (
|
||||
<li
|
||||
key={t.id}
|
||||
onClick={() => {
|
||||
onAdd(t);
|
||||
setKeyword("")
|
||||
setTags([])
|
||||
const input = document.getElementById("search_tags_input") as HTMLInputElement
|
||||
input.blur()
|
||||
}}><a>
|
||||
setKeyword("");
|
||||
setTags([]);
|
||||
const input = document.getElementById(
|
||||
"search_tags_input",
|
||||
) as HTMLInputElement;
|
||||
input.blur();
|
||||
}}
|
||||
>
|
||||
<a>
|
||||
<span>{t.name}</span>
|
||||
{t.type && <span className="badge badge-secondary badge-sm ml-2 text-xs">{t.type}</span>}
|
||||
</a></li>
|
||||
})
|
||||
}
|
||||
{
|
||||
!haveExactMatch && <li onClick={() => {
|
||||
handleCreateTag(keyword)
|
||||
}}><a>{t("Create Tag")}: {keyword}</a></li>
|
||||
}
|
||||
{t.type && (
|
||||
<span className="badge badge-secondary badge-sm ml-2 text-xs">
|
||||
{t.type}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!haveExactMatch && (
|
||||
<li
|
||||
onClick={() => {
|
||||
handleCreateTag(keyword);
|
||||
}}
|
||||
>
|
||||
<a>
|
||||
{t("Create Tag")}: {keyword}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={"dropdown dropdown-end"}>
|
||||
return (
|
||||
<div className={"dropdown dropdown-end"}>
|
||||
<label className="input w-64">
|
||||
<MdSearch size={18} />
|
||||
<input autoComplete={"off"} id={"search_tags_input"} tabIndex={0} type="text" className="grow" placeholder={t("Search Tags")} value={keyword} onChange={(e) => handleChange(e.target.value)} />
|
||||
<input
|
||||
autoComplete={"off"}
|
||||
id={"search_tags_input"}
|
||||
tabIndex={0}
|
||||
type="text"
|
||||
className="grow"
|
||||
placeholder={t("Search Tags")}
|
||||
value={keyword}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<ul tabIndex={0} className="dropdown-content menu bg-base-100 rounded-box z-1 w-64 p-2 shadow mt-2 border border-base-300">
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content menu bg-base-100 rounded-box z-1 w-64 p-2 shadow mt-2 border border-base-300"
|
||||
>
|
||||
{dropdownContent}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class Debounce {
|
||||
private timer: number | null = null
|
||||
private readonly delay: number
|
||||
private timer: number | null = null;
|
||||
private readonly delay: number;
|
||||
|
||||
constructor(delay: number) {
|
||||
this.delay = delay
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
run(callback: () => void) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
callback()
|
||||
}, this.delay)
|
||||
callback();
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickAddTagDialog({ onAdded }: { onAdded: (tags: Tag[]) => void }) {
|
||||
export function QuickAddTagDialog({
|
||||
onAdded,
|
||||
}: {
|
||||
onAdded: (tags: Tag[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [text, setText] = useState<string>("")
|
||||
const [text, setText] = useState<string>("");
|
||||
|
||||
const [type, setType] = useState<string>("")
|
||||
const [type, setType] = useState<string>("");
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [separator, setSeparator] = useState<string>(",")
|
||||
const [separator, setSeparator] = useState<string>(",");
|
||||
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isLoading) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (text.trim().length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
setError(null)
|
||||
const names = text.split(separator).filter((n) => n.length > 0)
|
||||
setLoading(true)
|
||||
const res = await network.getOrCreateTags(names, type)
|
||||
setLoading(false)
|
||||
setError(null);
|
||||
const names = text.split(separator).filter((n) => n.length > 0);
|
||||
setLoading(true);
|
||||
const res = await network.getOrCreateTags(names, type);
|
||||
setLoading(false);
|
||||
if (!res.success) {
|
||||
setError(res.message)
|
||||
return
|
||||
}
|
||||
const tags = res.data!
|
||||
onAdded(tags)
|
||||
setText("")
|
||||
setType("")
|
||||
const dialog = document.getElementById("quick_add_tag_dialog") as HTMLDialogElement
|
||||
dialog.close()
|
||||
setError(res.message);
|
||||
return;
|
||||
}
|
||||
const tags = res.data!;
|
||||
onAdded(tags);
|
||||
setText("");
|
||||
setType("");
|
||||
const dialog = document.getElementById(
|
||||
"quick_add_tag_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
};
|
||||
|
||||
return <>
|
||||
<Button className={"btn-soft btn-primary"} onClick={() => {
|
||||
const dialog = document.getElementById("quick_add_tag_dialog") as HTMLDialogElement
|
||||
dialog.showModal()
|
||||
}}>{t("Quick Add")}</Button>
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className={"btn-soft btn-primary"}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"quick_add_tag_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}
|
||||
>
|
||||
{t("Quick Add")}
|
||||
</Button>
|
||||
<dialog id="quick_add_tag_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t('Add Tags')}</h3>
|
||||
<h3 className="font-bold text-lg">{t("Add Tags")}</h3>
|
||||
<p className="py-2 text-sm">
|
||||
{t("Input tags separated by separator.")}
|
||||
<br />
|
||||
@@ -202,28 +271,62 @@ export function QuickAddTagDialog({ onAdded }: { onAdded: (tags: Tag[]) => void
|
||||
<p className={"flex my-2"}>
|
||||
<span className={"flex-1"}>{t("Separator")}:</span>
|
||||
<label className="label text-sm mx-2">
|
||||
<input type="radio" name="radio-1" className="radio radio-primary" checked={separator == ","} onChange={() => setSeparator(",")}/>
|
||||
<input
|
||||
type="radio"
|
||||
name="radio-1"
|
||||
className="radio radio-primary"
|
||||
checked={separator == ","}
|
||||
onChange={() => setSeparator(",")}
|
||||
/>
|
||||
Comma
|
||||
</label>
|
||||
<label className="label text-sm mx-2">
|
||||
<input type="radio" name="radio-2" className="radio radio-primary" checked={separator == ";"} onChange={() => setSeparator(";")}/>
|
||||
<input
|
||||
type="radio"
|
||||
name="radio-2"
|
||||
className="radio radio-primary"
|
||||
checked={separator == ";"}
|
||||
onChange={() => setSeparator(";")}
|
||||
/>
|
||||
Semicolon
|
||||
</label>
|
||||
<label className="label text-sm mx-2">
|
||||
<input type="radio" name="radio-3" className="radio radio-primary" checked={separator == " "} onChange={() => setSeparator(" ")}/>
|
||||
<input
|
||||
type="radio"
|
||||
name="radio-3"
|
||||
className="radio radio-primary"
|
||||
checked={separator == " "}
|
||||
onChange={() => setSeparator(" ")}
|
||||
/>
|
||||
Space
|
||||
</label>
|
||||
</p>
|
||||
<TextArea value={text} onChange={(e) => setText(e.target.value)} label={"Tags"}/>
|
||||
<Input value={type} onChange={(e) => setType(e.target.value)} label={"Type"}/>
|
||||
<TextArea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
label={"Tags"}
|
||||
/>
|
||||
<Input
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
label={"Type"}
|
||||
/>
|
||||
{error && <ErrorAlert className={"mt-2"} message={error} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button className="btn">{t("Cancel")}</Button>
|
||||
</form>
|
||||
<Button isLoading={isLoading} className={"btn-primary"} disabled={text === ""} onClick={handleSubmit}>{t("Submit")}</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className={"btn-primary"}
|
||||
disabled={text === ""}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t("Submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,14 +1,20 @@
|
||||
export default function showToast({message, type}: {message: string, type?: "success" | "error" | "warning" | "info"}) {
|
||||
type = type || "info"
|
||||
const div = document.createElement("div")
|
||||
export default function showToast({
|
||||
message,
|
||||
type,
|
||||
}: {
|
||||
message: string;
|
||||
type?: "success" | "error" | "warning" | "info";
|
||||
}) {
|
||||
type = type || "info";
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = `
|
||||
<div class="toast toast-center">
|
||||
<div class="alert shadow ${type === "success" && "alert-success"} ${type === "error" && "alert-error"} ${type === 'warning' && "alert-warning"} ${type === "info" && "alert-info"}">
|
||||
<div class="alert shadow ${type === "success" && "alert-success"} ${type === "error" && "alert-error"} ${type === "warning" && "alert-warning"} ${type === "info" && "alert-info"}">
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
</div>`
|
||||
document.body.appendChild(div)
|
||||
</div>`;
|
||||
document.body.appendChild(div);
|
||||
setTimeout(() => {
|
||||
div.remove()
|
||||
}, 3000)
|
||||
div.remove();
|
||||
}, 3000);
|
||||
}
|
@@ -7,32 +7,47 @@ export default function UploadingSideBar() {
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
console.log("Uploading tasks changed; show uploading: ", uploadingManager.hasTasks());
|
||||
setShowUploading(uploadingManager.hasTasks())
|
||||
console.log(
|
||||
"Uploading tasks changed; show uploading: ",
|
||||
uploadingManager.hasTasks(),
|
||||
);
|
||||
setShowUploading(uploadingManager.hasTasks());
|
||||
};
|
||||
|
||||
uploadingManager.addListener(listener)
|
||||
uploadingManager.addListener(listener);
|
||||
|
||||
return () => {
|
||||
uploadingManager.removeListener(listener)
|
||||
}
|
||||
uploadingManager.removeListener(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!showUploading) {
|
||||
return <></>
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<label htmlFor={"uploading-drawer"} className={"btn btn-square btn-ghost relative btn-accent text-primary"}>
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
htmlFor={"uploading-drawer"}
|
||||
className={"btn btn-square btn-ghost relative btn-accent text-primary"}
|
||||
>
|
||||
<div className={"w-6 h-6 overflow-hidden relative"}>
|
||||
<MdArrowUpward className={"move-up-animation pb-0.5"} size={24} />
|
||||
<div className={"absolute border-b-2 w-5 bottom-1 left-0.5"}></div>
|
||||
</div>
|
||||
</label>
|
||||
<div className="drawer w-0">
|
||||
<input id="uploading-drawer" type="checkbox" className="drawer-toggle" />
|
||||
<input
|
||||
id="uploading-drawer"
|
||||
type="checkbox"
|
||||
className="drawer-toggle"
|
||||
/>
|
||||
<div className="drawer-side">
|
||||
<label htmlFor="uploading-drawer" aria-label="close sidebar" className="drawer-overlay"></label>
|
||||
<label
|
||||
htmlFor="uploading-drawer"
|
||||
aria-label="close sidebar"
|
||||
className="drawer-overlay"
|
||||
></label>
|
||||
<div className="menu bg-base-200 text-base-content h-full w-80 p-4 overflow-y-auto ">
|
||||
<div className={"grid grid-cols-1"}>
|
||||
<h2 className={"text-xl mb-2"}>Uploading</h2>
|
||||
@@ -42,6 +57,7 @@ export default function UploadingSideBar() {
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadingList() {
|
||||
@@ -50,22 +66,22 @@ function UploadingList() {
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
setTasks(uploadingManager.getTasks());
|
||||
}
|
||||
};
|
||||
|
||||
uploadingManager.addListener(listener)
|
||||
uploadingManager.addListener(listener);
|
||||
|
||||
return () => {
|
||||
uploadingManager.removeListener(listener)
|
||||
}
|
||||
uploadingManager.removeListener(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>
|
||||
{
|
||||
tasks.map((task) => {
|
||||
return <TaskTile key={task.id} task={task} />
|
||||
})
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{tasks.map((task) => {
|
||||
return <TaskTile key={task.id} task={task} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskTile({ task }: { task: UploadingTask }) {
|
||||
@@ -77,31 +93,46 @@ function TaskTile({ task }: { task: UploadingTask }) {
|
||||
const listener = () => {
|
||||
setProgress(task.progress);
|
||||
setError(task.errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
task.addListener(listener)
|
||||
task.addListener(listener);
|
||||
|
||||
return () => {
|
||||
task.removeListener(listener)
|
||||
}
|
||||
task.removeListener(listener);
|
||||
};
|
||||
}, [task]);
|
||||
|
||||
return <div className={"card card-border border-base-300 p-2 my-2 w-full"}>
|
||||
<p className={"p-1 mb-2 w-full break-all line-clamp-2"}>{task.filename}</p>
|
||||
<progress className="progress progress-primary my-2" value={100 * progress} max={100} />
|
||||
return (
|
||||
<div className={"card card-border border-base-300 p-2 my-2 w-full"}>
|
||||
<p className={"p-1 mb-2 w-full break-all line-clamp-2"}>
|
||||
{task.filename}
|
||||
</p>
|
||||
<progress
|
||||
className="progress progress-primary my-2"
|
||||
value={100 * progress}
|
||||
max={100}
|
||||
/>
|
||||
{error && <p className={"text-error p-1"}>{error}</p>}
|
||||
<div className={"my-2 flex flex-row-reverse"}>
|
||||
{
|
||||
error && <button className={"btn h-7 mr-1"} onClick={() => {
|
||||
{error && (
|
||||
<button
|
||||
className={"btn h-7 mr-1"}
|
||||
onClick={() => {
|
||||
task.start();
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
}
|
||||
<button className={"btn btn-error h-7"} onClick={() => {
|
||||
const dialog = document.getElementById(`cancel_task_${task.id}`) as HTMLDialogElement;
|
||||
)}
|
||||
<button
|
||||
className={"btn btn-error h-7"}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
`cancel_task_${task.id}`,
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -113,15 +144,22 @@ function TaskTile({ task }: { task: UploadingTask }) {
|
||||
<form method="dialog">
|
||||
<button className="btn">Close</button>
|
||||
</form>
|
||||
<button className="btn btn-error mx-2" type={"button"} onClick={() => {
|
||||
<button
|
||||
className="btn btn-error mx-2"
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
task.cancel();
|
||||
const dialog = document.getElementById(`cancel_task_${task.id}`) as HTMLDialogElement;
|
||||
const dialog = document.getElementById(
|
||||
`cancel_task_${task.id}`,
|
||||
) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,85 +1,94 @@
|
||||
export const i18nData = {
|
||||
"en": {
|
||||
en: {
|
||||
translation: {
|
||||
"My Profile": "My Profile",
|
||||
"Publish": "Publish",
|
||||
Publish: "Publish",
|
||||
"Log out": "Log out",
|
||||
"Are you sure you want to log out?": "Are you sure you want to log out?",
|
||||
"Cancel": "Cancel",
|
||||
"Confirm": "Confirm",
|
||||
"Search": "Search",
|
||||
"Login": "Login",
|
||||
"Register": "Register",
|
||||
"Username": "Username",
|
||||
"Password": "Password",
|
||||
Cancel: "Cancel",
|
||||
Confirm: "Confirm",
|
||||
Search: "Search",
|
||||
Login: "Login",
|
||||
Register: "Register",
|
||||
Username: "Username",
|
||||
Password: "Password",
|
||||
"Confirm Password": "Confirm Password",
|
||||
"Username and password cannot be empty": "Username and password cannot be empty",
|
||||
"Username and password cannot be empty":
|
||||
"Username and password cannot be empty",
|
||||
"Passwords do not match": "Passwords do not match",
|
||||
"Continue": "Continue",
|
||||
Continue: "Continue",
|
||||
"Don't have an account? Register": "Don't have an account? Register",
|
||||
"Already have an account? Login": "Already have an account? Login",
|
||||
"Publish Resource": "Publish Resource",
|
||||
"All information can be modified after publishing": "All information can be modified after publishing",
|
||||
"Title": "Title",
|
||||
"All information can be modified after publishing":
|
||||
"All information can be modified after publishing",
|
||||
Title: "Title",
|
||||
"Alternative Titles": "Alternative Titles",
|
||||
"Add Alternative Title": "Add Alternative Title",
|
||||
"Tags": "Tags",
|
||||
"Description": "Description",
|
||||
Tags: "Tags",
|
||||
Description: "Description",
|
||||
"Use Markdown format": "Use Markdown format",
|
||||
"Images": "Images",
|
||||
"Images will not be displayed automatically, you need to reference them in the description": "Images will not be displayed automatically, you need to reference them in the description",
|
||||
"Preview": "Preview",
|
||||
"Link": "Link",
|
||||
"Action": "Action",
|
||||
Images: "Images",
|
||||
"Images will not be displayed automatically, you need to reference them in the description":
|
||||
"Images will not be displayed automatically, you need to reference them in the description",
|
||||
Preview: "Preview",
|
||||
Link: "Link",
|
||||
Action: "Action",
|
||||
"Upload Image": "Upload Image",
|
||||
"Error": "Error",
|
||||
Error: "Error",
|
||||
"Title cannot be empty": "Title cannot be empty",
|
||||
"Alternative title cannot be empty": "Alternative title cannot be empty",
|
||||
"At least one tag required": "At least one tag required",
|
||||
"Description cannot be empty": "Description cannot be empty",
|
||||
"Loading": "Loading",
|
||||
"Enter a search keyword to continue": "Enter a search keyword to continue",
|
||||
Loading: "Loading",
|
||||
"Enter a search keyword to continue":
|
||||
"Enter a search keyword to continue",
|
||||
"My Info": "My Info",
|
||||
"Server": "Server",
|
||||
Server: "Server",
|
||||
|
||||
// Management page translations
|
||||
"Manage": "Manage",
|
||||
"Storage": "Storage",
|
||||
"Users": "Users",
|
||||
"You are not logged in. Please log in to access this page.": "You are not logged in. Please log in to access this page.",
|
||||
"You are not authorized to access this page.": "You are not authorized to access this page.",
|
||||
Manage: "Manage",
|
||||
Storage: "Storage",
|
||||
Users: "Users",
|
||||
"You are not logged in. Please log in to access this page.":
|
||||
"You are not logged in. Please log in to access this page.",
|
||||
"You are not authorized to access this page.":
|
||||
"You are not authorized to access this page.",
|
||||
|
||||
// Storage management
|
||||
"No storage found. Please create a new storage.": "No storage found. Please create a new storage.",
|
||||
"Name": "Name",
|
||||
"No storage found. Please create a new storage.":
|
||||
"No storage found. Please create a new storage.",
|
||||
Name: "Name",
|
||||
"Created At": "Created At",
|
||||
"Actions": "Actions",
|
||||
Actions: "Actions",
|
||||
"Delete Storage": "Delete Storage",
|
||||
"Are you sure you want to delete this storage? This action cannot be undone.": "Are you sure you want to delete this storage? This action cannot be undone.",
|
||||
"Delete": "Delete",
|
||||
"Are you sure you want to delete this storage? This action cannot be undone.":
|
||||
"Are you sure you want to delete this storage? This action cannot be undone.",
|
||||
Delete: "Delete",
|
||||
"Storage deleted successfully": "Storage deleted successfully",
|
||||
"New Storage": "New Storage",
|
||||
"Type": "Type",
|
||||
"Local": "Local",
|
||||
"S3": "S3",
|
||||
"Path": "Path",
|
||||
Type: "Type",
|
||||
Local: "Local",
|
||||
S3: "S3",
|
||||
Path: "Path",
|
||||
"Max Size (MB)": "Max Size (MB)",
|
||||
"Endpoint": "Endpoint",
|
||||
Endpoint: "Endpoint",
|
||||
"Access Key ID": "Access Key ID",
|
||||
"Secret Access Key": "Secret Access Key",
|
||||
"Bucket Name": "Bucket Name",
|
||||
"All fields are required": "All fields are required",
|
||||
"Storage created successfully": "Storage created successfully",
|
||||
"Close": "Close",
|
||||
"Submit": "Submit",
|
||||
Close: "Close",
|
||||
Submit: "Submit",
|
||||
|
||||
// User management
|
||||
"Admin": "Admin",
|
||||
Admin: "Admin",
|
||||
"Can Upload": "Can Upload",
|
||||
"Yes": "Yes",
|
||||
"No": "No",
|
||||
Yes: "Yes",
|
||||
No: "No",
|
||||
"Delete User": "Delete User",
|
||||
"Are you sure you want to delete user": "Are you sure you want to delete user",
|
||||
"Are you sure you want to delete user":
|
||||
"Are you sure you want to delete user",
|
||||
"This action cannot be undone.": "This action cannot be undone.",
|
||||
"User deleted successfully": "User deleted successfully",
|
||||
"Set as user": "Set as user",
|
||||
@@ -88,31 +97,38 @@ export const i18nData = {
|
||||
"Grant upload permission": "Grant upload permission",
|
||||
"User set as admin successfully": "User set as admin successfully",
|
||||
"User set as user successfully": "User set as user successfully",
|
||||
"User set as upload permission successfully": "User set as upload permission successfully",
|
||||
"User removed upload permission successfully": "User removed upload permission successfully",
|
||||
"User set as upload permission successfully":
|
||||
"User set as upload permission successfully",
|
||||
"User removed upload permission successfully":
|
||||
"User removed upload permission successfully",
|
||||
|
||||
// Resource details page
|
||||
"Resource ID is required": "Resource ID is required",
|
||||
"Files": "Files",
|
||||
"Comments": "Comments",
|
||||
"Upload": "Upload",
|
||||
Files: "Files",
|
||||
Comments: "Comments",
|
||||
Upload: "Upload",
|
||||
"Create File": "Create File",
|
||||
"Please select a file type": "Please select a file type",
|
||||
"Please fill in all fields": "Please fill in all fields",
|
||||
"File created successfully": "File created successfully",
|
||||
"Successfully create uploading task.": "Successfully create uploading task.",
|
||||
"Successfully create uploading task.":
|
||||
"Successfully create uploading task.",
|
||||
"Please select a file and storage": "Please select a file and storage",
|
||||
"Redirect": "Redirect",
|
||||
"User who click the file will be redirected to the URL": "User who click the file will be redirected to the URL",
|
||||
Redirect: "Redirect",
|
||||
"User who click the file will be redirected to the URL":
|
||||
"User who click the file will be redirected to the URL",
|
||||
"File Name": "File Name",
|
||||
"URL": "URL",
|
||||
"Upload a file to server, then the file will be moved to the selected storage.": "Upload a file to server, then the file will be moved to the selected storage.",
|
||||
URL: "URL",
|
||||
"Upload a file to server, then the file will be moved to the selected storage.":
|
||||
"Upload a file to server, then the file will be moved to the selected storage.",
|
||||
"Select Storage": "Select Storage",
|
||||
"Resource Details": "Resource Details",
|
||||
"Delete Resource": "Delete Resource",
|
||||
"Are you sure you want to delete the resource": "Are you sure you want to delete the resource",
|
||||
"Are you sure you want to delete the resource":
|
||||
"Are you sure you want to delete the resource",
|
||||
"Delete File": "Delete File",
|
||||
"Are you sure you want to delete the file": "Are you sure you want to delete the file",
|
||||
"Are you sure you want to delete the file":
|
||||
"Are you sure you want to delete the file",
|
||||
|
||||
// New translations
|
||||
"Change Avatar": "Change Avatar",
|
||||
@@ -120,7 +136,7 @@ export const i18nData = {
|
||||
"Change Password": "Change Password",
|
||||
"New Username": "New Username",
|
||||
"Enter new username": "Enter new username",
|
||||
"Save": "Save",
|
||||
Save: "Save",
|
||||
"Current Password": "Current Password",
|
||||
"Enter current password": "Enter current password",
|
||||
"New Password": "New Password",
|
||||
@@ -135,14 +151,17 @@ export const i18nData = {
|
||||
"Update server config successfully": "Update server config successfully",
|
||||
"Max uploading size (MB)": "Max uploading size (MB)",
|
||||
"Max file size (MB)": "Max file size (MB)",
|
||||
"Max downloads per day for single IP": "Max downloads per day for single IP",
|
||||
"Max downloads per day for single IP":
|
||||
"Max downloads per day for single IP",
|
||||
"Allow register": "Allow register",
|
||||
"Server name": "Server name",
|
||||
"Server description": "Server description",
|
||||
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile Site Key",
|
||||
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile Secret Key",
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.": "If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.",
|
||||
"The first image will be used as the cover image": "The first image will be used as the cover image",
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.",
|
||||
"The first image will be used as the cover image":
|
||||
"The first image will be used as the cover image",
|
||||
"Please enter a search keyword": "Please enter a search keyword",
|
||||
"Searching...": "Searching...",
|
||||
"Create Tag": "Create Tag",
|
||||
@@ -153,7 +172,7 @@ export const i18nData = {
|
||||
"Tag not found": "Tag not found",
|
||||
"Description is too long": "Description is too long",
|
||||
"Unknown error": "Unknown error",
|
||||
"Edit": "Edit",
|
||||
Edit: "Edit",
|
||||
"Edit Tag": "Edit Tag",
|
||||
"Set the description of the tag.": "Set the description of the tag.",
|
||||
"Use markdown format.": "Use markdown format.",
|
||||
@@ -166,99 +185,109 @@ export const i18nData = {
|
||||
"Downloads Ascending": "Downloads Ascending",
|
||||
"Downloads Descending": "Downloads Descending",
|
||||
"File Url": "File Url",
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.": "Provide a file url for the server to download, and the file will be moved to the selected storage.",
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.",
|
||||
"Verifying your request": "Verifying your request",
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.": "Please check your network if the verification takes too long or the captcha does not appear.",
|
||||
"About": "About",
|
||||
"Home": "Home",
|
||||
"Other": "Other",
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.":
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.",
|
||||
About: "About",
|
||||
Home: "Home",
|
||||
Other: "Other",
|
||||
"Quick Add": "Quick Add",
|
||||
"Add Tags": "Add Tags",
|
||||
"Input tags separated by separator.": "Input tags separated by separator.",
|
||||
"If the tag does not exist, it will be created automatically.": "If the tag does not exist, it will be created automatically.",
|
||||
"Optionally, you can specify a type for the new tags.": "Optionally, you can specify a type for the new tags.",
|
||||
"Input tags separated by separator.":
|
||||
"Input tags separated by separator.",
|
||||
"If the tag does not exist, it will be created automatically.":
|
||||
"If the tag does not exist, it will be created automatically.",
|
||||
"Optionally, you can specify a type for the new tags.":
|
||||
"Optionally, you can specify a type for the new tags.",
|
||||
"Upload Clipboard Image": "Upload Clipboard Image",
|
||||
}
|
||||
},
|
||||
},
|
||||
"zh-CN": {
|
||||
translation: {
|
||||
"My Profile": "我的资料",
|
||||
"Publish": "发布",
|
||||
Publish: "发布",
|
||||
"Log out": "退出登录",
|
||||
"Are you sure you want to log out?": "您确定要退出登录吗?",
|
||||
"Cancel": "取消",
|
||||
"Confirm": "确认",
|
||||
"Search": "搜索",
|
||||
"Login": "登录",
|
||||
"Register": "注册",
|
||||
"Username": "用户名",
|
||||
"Password": "密码",
|
||||
Cancel: "取消",
|
||||
Confirm: "确认",
|
||||
Search: "搜索",
|
||||
Login: "登录",
|
||||
Register: "注册",
|
||||
Username: "用户名",
|
||||
Password: "密码",
|
||||
"Confirm Password": "确认密码",
|
||||
"Username and password cannot be empty": "用户名和密码不能为空",
|
||||
"Passwords do not match": "两次输入的密码不匹配",
|
||||
"Continue": "继续",
|
||||
Continue: "继续",
|
||||
"Don't have an account? Register": "没有账号?注册",
|
||||
"Already have an account? Login": "已有账号?登录",
|
||||
"Publish Resource": "发布资源",
|
||||
"All information can be modified after publishing": "所有的信息均可在发布后修改",
|
||||
"Title": "标题",
|
||||
"All information can be modified after publishing":
|
||||
"所有的信息均可在发布后修改",
|
||||
Title: "标题",
|
||||
"Alternative Titles": "其他标题",
|
||||
"Add Alternative Title": "新增标题",
|
||||
"Tags": "标签",
|
||||
"Description": "介绍",
|
||||
Tags: "标签",
|
||||
Description: "介绍",
|
||||
"Use Markdown format": "使用Markdown格式",
|
||||
"Images": "图片",
|
||||
"Images will not be displayed automatically, you need to reference them in the description": "图片不会被自动显示, 你需要在介绍中引用它们",
|
||||
"Preview": "预览",
|
||||
"Link": "链接",
|
||||
"Action": "操作",
|
||||
Images: "图片",
|
||||
"Images will not be displayed automatically, you need to reference them in the description":
|
||||
"图片不会被自动显示, 你需要在介绍中引用它们",
|
||||
Preview: "预览",
|
||||
Link: "链接",
|
||||
Action: "操作",
|
||||
"Upload Image": "上传图片",
|
||||
"Error": "错误",
|
||||
Error: "错误",
|
||||
"Title cannot be empty": "标题不能为空",
|
||||
"Alternative title cannot be empty": "不能存在空标题",
|
||||
"At least one tag required": "至少选择一个标签",
|
||||
"Description cannot be empty": "介绍不能为空",
|
||||
"Loading": "加载中",
|
||||
Loading: "加载中",
|
||||
"Enter a search keyword to continue": "输入搜索关键词以继续",
|
||||
"My Info": "个人信息",
|
||||
"Server": "服务器",
|
||||
Server: "服务器",
|
||||
|
||||
// Management page translations
|
||||
"Manage": "管理",
|
||||
"Storage": "存储",
|
||||
"Users": "用户",
|
||||
"You are not logged in. Please log in to access this page.": "您尚未登录。请登录以访问此页面。",
|
||||
Manage: "管理",
|
||||
Storage: "存储",
|
||||
Users: "用户",
|
||||
"You are not logged in. Please log in to access this page.":
|
||||
"您尚未登录。请登录以访问此页面。",
|
||||
"You are not authorized to access this page.": "您无权访问此页面。",
|
||||
|
||||
// Storage management
|
||||
"No storage found. Please create a new storage.": "未找到存储。请创建新的存储。",
|
||||
"Name": "名称",
|
||||
"No storage found. Please create a new storage.":
|
||||
"未找到存储。请创建新的存储。",
|
||||
Name: "名称",
|
||||
"Created At": "创建于",
|
||||
"Actions": "操作",
|
||||
Actions: "操作",
|
||||
"Delete Storage": "删除存储",
|
||||
"Are you sure you want to delete this storage? This action cannot be undone.": "您确定要删除此存储吗?此操作不可撤销。",
|
||||
"Delete": "删除",
|
||||
"Are you sure you want to delete this storage? This action cannot be undone.":
|
||||
"您确定要删除此存储吗?此操作不可撤销。",
|
||||
Delete: "删除",
|
||||
"Storage deleted successfully": "存储已成功删除",
|
||||
"New Storage": "新建存储",
|
||||
"Type": "类型",
|
||||
"Local": "本地",
|
||||
"S3": "S3",
|
||||
"Path": "路径",
|
||||
Type: "类型",
|
||||
Local: "本地",
|
||||
S3: "S3",
|
||||
Path: "路径",
|
||||
"Max Size (MB)": "最大大小 (MB)",
|
||||
"Endpoint": "终端节点",
|
||||
Endpoint: "终端节点",
|
||||
"Access Key ID": "访问密钥 ID",
|
||||
"Secret Access Key": "私有访问密钥",
|
||||
"Bucket Name": "桶名称",
|
||||
"All fields are required": "所有字段都是必填的",
|
||||
"Storage created successfully": "存储创建成功",
|
||||
"Close": "关闭",
|
||||
"Submit": "提交",
|
||||
Close: "关闭",
|
||||
Submit: "提交",
|
||||
|
||||
// User management
|
||||
"Admin": "管理员",
|
||||
Admin: "管理员",
|
||||
"Can Upload": "可上传",
|
||||
"Yes": "是",
|
||||
"No": "否",
|
||||
Yes: "是",
|
||||
No: "否",
|
||||
"Delete User": "删除用户",
|
||||
"Are you sure you want to delete user": "您确定要删除用户",
|
||||
"This action cannot be undone.": "此操作不可撤销。",
|
||||
@@ -274,20 +303,22 @@ export const i18nData = {
|
||||
|
||||
// Resource details page
|
||||
"Resource ID is required": "资源ID是必需的",
|
||||
"Files": "文件",
|
||||
"Comments": "评论",
|
||||
"Upload": "上传",
|
||||
Files: "文件",
|
||||
Comments: "评论",
|
||||
Upload: "上传",
|
||||
"Create File": "创建文件",
|
||||
"Please select a file type": "请选择文件类型",
|
||||
"Please fill in all fields": "请填写所有字段",
|
||||
"File created successfully": "文件创建成功",
|
||||
"Successfully create uploading task.": "成功创建上传任务。",
|
||||
"Please select a file and storage": "请选择文件和存储",
|
||||
"Redirect": "重定向",
|
||||
"User who click the file will be redirected to the URL": "点击文件的用户将被重定向到URL",
|
||||
Redirect: "重定向",
|
||||
"User who click the file will be redirected to the URL":
|
||||
"点击文件的用户将被重定向到URL",
|
||||
"File Name": "文件名",
|
||||
"URL": "URL",
|
||||
"Upload a file to server, then the file will be moved to the selected storage.": "将文件上传到服务器,然后文件将被移动到选定的存储中。",
|
||||
URL: "URL",
|
||||
"Upload a file to server, then the file will be moved to the selected storage.":
|
||||
"将文件上传到服务器,然后文件将被移动到选定的存储中。",
|
||||
"Select Storage": "选择存储",
|
||||
"Resource Details": "资源详情",
|
||||
"Delete Resource": "删除资源",
|
||||
@@ -301,7 +332,7 @@ export const i18nData = {
|
||||
"Change Password": "更改密码",
|
||||
"New Username": "新用户名",
|
||||
"Enter new username": "输入新用户名",
|
||||
"Save": "保存",
|
||||
Save: "保存",
|
||||
"Current Password": "当前密码",
|
||||
"Enter current password": "输入当前密码",
|
||||
"New Password": "新密码",
|
||||
@@ -322,8 +353,10 @@ export const i18nData = {
|
||||
"Server description": "服务器描述",
|
||||
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile 站点密钥",
|
||||
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密钥",
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.": "如果设置了 Cloudflare Turnstile 密钥,将在注册和下载时启用验证",
|
||||
"The first image will be used as the cover image": "第一张图片将用作封面图片",
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
|
||||
"如果设置了 Cloudflare Turnstile 密钥,将在注册和下载时启用验证",
|
||||
"The first image will be used as the cover image":
|
||||
"第一张图片将用作封面图片",
|
||||
"Please enter a search keyword": "请输入搜索关键词",
|
||||
"Searching...": "搜索中...",
|
||||
"Create Tag": "创建标签",
|
||||
@@ -334,7 +367,7 @@ export const i18nData = {
|
||||
"Tag not found": "标签未找到",
|
||||
"Description is too long": "描述太长",
|
||||
"Unknown error": "未知错误",
|
||||
"Edit": "编辑",
|
||||
Edit: "编辑",
|
||||
"Edit Tag": "编辑标签",
|
||||
"Set the description of the tag.": "设置标签的描述。",
|
||||
"Use markdown format.": "使用Markdown格式。",
|
||||
@@ -347,99 +380,108 @@ export const i18nData = {
|
||||
"Downloads Ascending": "下载量升序",
|
||||
"Downloads Descending": "下载量降序",
|
||||
"File Url": "文件链接",
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.": "提供一个文件链接供服务器下载,文件将被移动到选定的存储中。",
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
|
||||
"提供一个文件链接供服务器下载,文件将被移动到选定的存储中。",
|
||||
"Verifying your request": "正在验证您的请求",
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.": "如果验证时间过长或验证码未出现, 请检查您的网络连接",
|
||||
"About": "关于",
|
||||
"Home": "首页",
|
||||
"Other": "其他",
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.":
|
||||
"如果验证时间过长或验证码未出现, 请检查您的网络连接",
|
||||
About: "关于",
|
||||
Home: "首页",
|
||||
Other: "其他",
|
||||
"Quick Add": "快速添加",
|
||||
"Add Tags": "添加标签",
|
||||
"Input tags separated by separator.": "输入标签, 用分隔符分隔。",
|
||||
"If the tag does not exist, it will be created automatically.": "如果标签不存在, 将自动创建。",
|
||||
"Optionally, you can specify a type for the new tags.": "您可以选择为新标签指定一个类型。",
|
||||
"If the tag does not exist, it will be created automatically.":
|
||||
"如果标签不存在, 将自动创建。",
|
||||
"Optionally, you can specify a type for the new tags.":
|
||||
"您可以选择为新标签指定一个类型。",
|
||||
"Upload Clipboard Image": "上传剪贴板图片",
|
||||
}
|
||||
},
|
||||
},
|
||||
"zh-TW": {
|
||||
translation: {
|
||||
"My Profile": "我的資料",
|
||||
"Publish": "發布",
|
||||
Publish: "發布",
|
||||
"Log out": "登出",
|
||||
"Are you sure you want to log out?": "您確定要登出嗎?",
|
||||
"Cancel": "取消",
|
||||
"Confirm": "確認",
|
||||
"Search": "搜尋",
|
||||
"Login": "登入",
|
||||
"Register": "註冊",
|
||||
"Username": "用戶名",
|
||||
"Password": "密碼",
|
||||
Cancel: "取消",
|
||||
Confirm: "確認",
|
||||
Search: "搜尋",
|
||||
Login: "登入",
|
||||
Register: "註冊",
|
||||
Username: "用戶名",
|
||||
Password: "密碼",
|
||||
"Confirm Password": "確認密碼",
|
||||
"Username and password cannot be empty": "用戶名和密碼不能為空",
|
||||
"Passwords do not match": "兩次輸入的密碼不匹配",
|
||||
"Continue": "繼續",
|
||||
Continue: "繼續",
|
||||
"Don't have an account? Register": "沒有賬號?註冊",
|
||||
"Already have an account? Login": "已有賬號?登入",
|
||||
"Publish Resource": "發布資源",
|
||||
"All information can be modified after publishing": "所有資訊均可於發布後修改",
|
||||
"Title": "標題",
|
||||
"All information can be modified after publishing":
|
||||
"所有資訊均可於發布後修改",
|
||||
Title: "標題",
|
||||
"Alternative Titles": "其他標題",
|
||||
"Add Alternative Title": "新增標題",
|
||||
"Tags": "標籤",
|
||||
"Description": "介紹",
|
||||
Tags: "標籤",
|
||||
Description: "介紹",
|
||||
"Use Markdown format": "使用Markdown格式",
|
||||
"Images": "圖片",
|
||||
"Images will not be displayed automatically, you need to reference them in the description": "圖片不會自動顯示,需在介紹中引用",
|
||||
"Preview": "預覽",
|
||||
"Link": "連結",
|
||||
"Action": "操作",
|
||||
Images: "圖片",
|
||||
"Images will not be displayed automatically, you need to reference them in the description":
|
||||
"圖片不會自動顯示,需在介紹中引用",
|
||||
Preview: "預覽",
|
||||
Link: "連結",
|
||||
Action: "操作",
|
||||
"Upload Image": "上傳圖片",
|
||||
"Error": "錯誤",
|
||||
Error: "錯誤",
|
||||
"Title cannot be empty": "標題不能為空",
|
||||
"Alternative title cannot be empty": "不能有空的標題",
|
||||
"At least one tag required": "至少選擇一個標籤",
|
||||
"Description cannot be empty": "介紹不能為空",
|
||||
"Loading": "載入中",
|
||||
Loading: "載入中",
|
||||
"Enter a search keyword to continue": "輸入搜尋關鍵字以繼續",
|
||||
"My Info": "個人信息",
|
||||
"Server": "伺服器",
|
||||
Server: "伺服器",
|
||||
|
||||
// Management page translations
|
||||
"Manage": "管理",
|
||||
"Storage": "儲存",
|
||||
"Users": "用戶",
|
||||
"You are not logged in. Please log in to access this page.": "您尚未登入。請登入以訪問此頁面。",
|
||||
Manage: "管理",
|
||||
Storage: "儲存",
|
||||
Users: "用戶",
|
||||
"You are not logged in. Please log in to access this page.":
|
||||
"您尚未登入。請登入以訪問此頁面。",
|
||||
"You are not authorized to access this page.": "您無權訪問此頁面。",
|
||||
|
||||
// Storage management
|
||||
"No storage found. Please create a new storage.": "未找到儲存。請創建新的儲存。",
|
||||
"Name": "名稱",
|
||||
"No storage found. Please create a new storage.":
|
||||
"未找到儲存。請創建新的儲存。",
|
||||
Name: "名稱",
|
||||
"Created At": "建立於",
|
||||
"Actions": "操作",
|
||||
Actions: "操作",
|
||||
"Delete Storage": "刪除儲存",
|
||||
"Are you sure you want to delete this storage? This action cannot be undone.": "您確定要刪除此儲存嗎?此操作不可撤銷。",
|
||||
"Delete": "刪除",
|
||||
"Are you sure you want to delete this storage? This action cannot be undone.":
|
||||
"您確定要刪除此儲存嗎?此操作不可撤銷。",
|
||||
Delete: "刪除",
|
||||
"Storage deleted successfully": "儲存已成功刪除",
|
||||
"New Storage": "新建儲存",
|
||||
"Type": "類型",
|
||||
"Local": "本地",
|
||||
"S3": "S3",
|
||||
"Path": "路徑",
|
||||
Type: "類型",
|
||||
Local: "本地",
|
||||
S3: "S3",
|
||||
Path: "路徑",
|
||||
"Max Size (MB)": "最大大小 (MB)",
|
||||
"Endpoint": "端點",
|
||||
Endpoint: "端點",
|
||||
"Access Key ID": "訪問密鑰 ID",
|
||||
"Secret Access Key": "私有訪問密鑰",
|
||||
"Bucket Name": "儲存桶名稱",
|
||||
"All fields are required": "所有欄位都是必填的",
|
||||
"Storage created successfully": "儲存創建成功",
|
||||
"Close": "關閉",
|
||||
"Submit": "提交",
|
||||
Close: "關閉",
|
||||
Submit: "提交",
|
||||
|
||||
// User management
|
||||
"Admin": "管理員",
|
||||
Admin: "管理員",
|
||||
"Can Upload": "可上傳",
|
||||
"Yes": "是",
|
||||
"No": "否",
|
||||
Yes: "是",
|
||||
No: "否",
|
||||
"Delete User": "刪除用戶",
|
||||
"Are you sure you want to delete user": "您確定要刪除用戶",
|
||||
"This action cannot be undone.": "此操作不可撤銷。",
|
||||
@@ -455,20 +497,22 @@ export const i18nData = {
|
||||
|
||||
// Resource details page
|
||||
"Resource ID is required": "資源ID是必需的",
|
||||
"Files": "檔案",
|
||||
"Comments": "評論",
|
||||
"Upload": "上傳",
|
||||
Files: "檔案",
|
||||
Comments: "評論",
|
||||
Upload: "上傳",
|
||||
"Create File": "創建檔案",
|
||||
"Please select a file type": "請選擇檔案類型",
|
||||
"Please fill in all fields": "請填寫所有欄位",
|
||||
"File created successfully": "檔案創建成功",
|
||||
"Successfully create uploading task.": "成功創建上傳任務。",
|
||||
"Please select a file and storage": "請選擇檔案和儲存",
|
||||
"Redirect": "重定向",
|
||||
"User who click the file will be redirected to the URL": "點擊檔案的用戶將被重定向到URL",
|
||||
Redirect: "重定向",
|
||||
"User who click the file will be redirected to the URL":
|
||||
"點擊檔案的用戶將被重定向到URL",
|
||||
"File Name": "檔案名",
|
||||
"URL": "URL",
|
||||
"Upload a file to server, then the file will be moved to the selected storage.": "將檔案上傳到伺服器,然後檔案將被移動到選定的儲存中。",
|
||||
URL: "URL",
|
||||
"Upload a file to server, then the file will be moved to the selected storage.":
|
||||
"將檔案上傳到伺服器,然後檔案將被移動到選定的儲存中。",
|
||||
"Select Storage": "選擇儲存",
|
||||
"Resource Details": "資源詳情",
|
||||
"Delete Resource": "刪除資源",
|
||||
@@ -482,7 +526,7 @@ export const i18nData = {
|
||||
"Change Password": "更改密碼",
|
||||
"New Username": "新用戶名",
|
||||
"Enter new username": "輸入新用戶名",
|
||||
"Save": "儲存",
|
||||
Save: "儲存",
|
||||
"Current Password": "當前密碼",
|
||||
"Enter current password": "輸入當前密碼",
|
||||
"New Password": "新密碼",
|
||||
@@ -503,8 +547,10 @@ export const i18nData = {
|
||||
"Server description": "伺服器描述",
|
||||
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile 網站密鑰",
|
||||
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密鑰",
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.": "如果設置了 Cloudflare Turnstile 密鑰,將在註冊和下載時啟用驗證",
|
||||
"The first image will be used as the cover image": "第一張圖片將用作封面圖片",
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
|
||||
"如果設置了 Cloudflare Turnstile 密鑰,將在註冊和下載時啟用驗證",
|
||||
"The first image will be used as the cover image":
|
||||
"第一張圖片將用作封面圖片",
|
||||
"Please enter a search keyword": "請輸入搜尋關鍵字",
|
||||
"Searching...": "搜尋中...",
|
||||
"Create Tag": "創建標籤",
|
||||
@@ -515,7 +561,7 @@ export const i18nData = {
|
||||
"Tag not found": "標籤未找到",
|
||||
"Description is too long": "描述太長",
|
||||
"Unknown error": "未知錯誤",
|
||||
"Edit": "編輯",
|
||||
Edit: "編輯",
|
||||
"Edit Tag": "編輯標籤",
|
||||
"Set the description of the tag.": "設置標籤的描述。",
|
||||
"Use markdown format.": "使用Markdown格式。",
|
||||
@@ -528,18 +574,22 @@ export const i18nData = {
|
||||
"Downloads Ascending": "下載量升序",
|
||||
"Downloads Descending": "下載量降序",
|
||||
"File Url": "檔案連結",
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.": "提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。",
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
|
||||
"提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。",
|
||||
"Verifying your request": "正在驗證您的請求",
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.": "如果驗證時間過長或驗證碼未出現,請檢查您的網絡連接。",
|
||||
"About": "關於",
|
||||
"Home": "首頁",
|
||||
"Other": "其他",
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.":
|
||||
"如果驗證時間過長或驗證碼未出現,請檢查您的網絡連接。",
|
||||
About: "關於",
|
||||
Home: "首頁",
|
||||
Other: "其他",
|
||||
"Quick Add": "快速添加",
|
||||
"Add Tags": "添加標籤",
|
||||
"Input tags separated by separator.": "輸入標籤, 用分隔符分隔。",
|
||||
"If the tag does not exist, it will be created automatically.": "如果標籤不存在, 將自動創建。",
|
||||
"Optionally, you can specify a type for the new tags.": "您可以選擇為新標籤指定一個類型。",
|
||||
"If the tag does not exist, it will be created automatically.":
|
||||
"如果標籤不存在, 將自動創建。",
|
||||
"Optionally, you can specify a type for the new tags.":
|
||||
"您可以選擇為新標籤指定一個類型。",
|
||||
"Upload Clipboard Image": "上傳剪貼板圖片",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./app.tsx";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
@@ -16,14 +16,15 @@ i18n
|
||||
debug: true,
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
}).then(() => {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<AppContext>
|
||||
<App />
|
||||
</AppContext>
|
||||
</StrictMode>,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
@@ -94,7 +94,8 @@ article {
|
||||
background-color: var(--color-base-200);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, system-ui;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, system-ui;
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,11 @@ class Listenable {
|
||||
}
|
||||
|
||||
removeListener(listener: () => void) {
|
||||
this.listeners = this.listeners.filter(l => l !== listener);
|
||||
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||
}
|
||||
|
||||
notifyListeners() {
|
||||
this.listeners.forEach(listener => listener());
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export class UploadingTask extends Listenable {
|
||||
uploadingBlocks: number[] = [];
|
||||
finishedBlocksCount: number = 0;
|
||||
|
||||
onFinished: (() => void);
|
||||
onFinished: () => void;
|
||||
|
||||
get filename() {
|
||||
return this.file.name;
|
||||
@@ -49,7 +49,13 @@ export class UploadingTask extends Listenable {
|
||||
return this.finishedBlocksCount / this.blocks.length;
|
||||
}
|
||||
|
||||
constructor(id: number, file: File, blocksCount: number, blockSize: number, onFinished: () => void) {
|
||||
constructor(
|
||||
id: number,
|
||||
file: File,
|
||||
blocksCount: number,
|
||||
blockSize: number,
|
||||
onFinished: () => void,
|
||||
) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.file = file;
|
||||
@@ -117,7 +123,7 @@ export class UploadingTask extends Listenable {
|
||||
}
|
||||
this.blocks[index] = true;
|
||||
this.finishedBlocksCount++;
|
||||
this.uploadingBlocks = this.uploadingBlocks.filter(i => i !== index);
|
||||
this.uploadingBlocks = this.uploadingBlocks.filter((i) => i !== index);
|
||||
index++;
|
||||
this.notifyListeners();
|
||||
}
|
||||
@@ -133,15 +139,14 @@ export class UploadingTask extends Listenable {
|
||||
this.upload(),
|
||||
this.upload(),
|
||||
this.upload(),
|
||||
])
|
||||
]);
|
||||
if (this.status !== UploadingStatus.UPLOADING) {
|
||||
return;
|
||||
}
|
||||
let md5 = "";
|
||||
try {
|
||||
md5 = await this.calculateMd5(this.file);
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
this.status = UploadingStatus.ERROR;
|
||||
this.errorMessage = "Failed to calculate md5";
|
||||
this.notifyListeners();
|
||||
@@ -180,40 +185,55 @@ class UploadingManager extends Listenable {
|
||||
this.tasks[0].removeListener(this.onTaskStatusChanged);
|
||||
this.tasks.shift();
|
||||
this.onTaskStatusChanged();
|
||||
} else if (this.tasks[0].status === UploadingStatus.ERROR && this.tasks[0].errorMessage === "Cancelled") {
|
||||
} else if (
|
||||
this.tasks[0].status === UploadingStatus.ERROR &&
|
||||
this.tasks[0].errorMessage === "Cancelled"
|
||||
) {
|
||||
this.tasks[0].removeListener(this.onTaskStatusChanged);
|
||||
this.tasks.shift();
|
||||
this.onTaskStatusChanged();
|
||||
}
|
||||
this.notifyListeners();
|
||||
}
|
||||
};
|
||||
|
||||
async addTask(file: File, resourceID: number, storageID: number, description: string, onFinished: () => void): Promise<Response<void>> {
|
||||
async addTask(
|
||||
file: File,
|
||||
resourceID: number,
|
||||
storageID: number,
|
||||
description: string,
|
||||
onFinished: () => void,
|
||||
): Promise<Response<void>> {
|
||||
const res = await network.initFileUpload(
|
||||
file.name,
|
||||
description,
|
||||
file.size,
|
||||
resourceID,
|
||||
storageID,
|
||||
)
|
||||
);
|
||||
if (!res.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: res.message,
|
||||
};
|
||||
}
|
||||
const task = new UploadingTask(res.data!.id, file, res.data!.blocksCount, res.data!.blockSize, onFinished);
|
||||
const task = new UploadingTask(
|
||||
res.data!.id,
|
||||
file,
|
||||
res.data!.blocksCount,
|
||||
res.data!.blockSize,
|
||||
onFinished,
|
||||
);
|
||||
task.addListener(this.onTaskStatusChanged);
|
||||
this.tasks.push(task);
|
||||
this.onTaskStatusChanged();
|
||||
return {
|
||||
success: true,
|
||||
message: "ok",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getTasks() {
|
||||
return Array.from(this.tasks)
|
||||
return Array.from(this.tasks);
|
||||
}
|
||||
|
||||
hasTasks() {
|
||||
@@ -228,4 +248,4 @@ window.addEventListener("beforeunload", () => {
|
||||
return "Uploading files, are you sure you want to leave?";
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
});
|
||||
|
@@ -3,36 +3,53 @@ 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
|
||||
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
|
||||
const first = props.children[0] as ReactNode;
|
||||
// @ts-ignore
|
||||
const second = props.children[1] as ReactNode
|
||||
const second = props.children[1] as ReactNode;
|
||||
|
||||
if (typeof first === "object" && (typeof second === "string" || typeof second === "object")) {
|
||||
const img = first as ReactElement
|
||||
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>
|
||||
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>
|
||||
}
|
||||
}}>
|
||||
return (
|
||||
<a href={href} target={"_blank"}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{app.siteInfo}
|
||||
</Markdown>
|
||||
</article>
|
||||
);
|
||||
}
|
@@ -9,101 +9,113 @@ 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 {
|
||||
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])
|
||||
}, [t]);
|
||||
|
||||
const {rid} = useParams()
|
||||
const id = parseInt(rid || "")
|
||||
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.")} />
|
||||
return (
|
||||
<ErrorAlert
|
||||
className={"m-4"}
|
||||
message={t("You are not logged in. Please log in to access this page.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading/>
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return <ImageDrapArea onUploaded={(images) => {
|
||||
setImages((prev) => ([...prev, ...images]));
|
||||
}}>
|
||||
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">
|
||||
@@ -111,78 +123,106 @@ export default function EditResourcePage() {
|
||||
<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)} />
|
||||
<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)
|
||||
}}>
|
||||
{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, ""])
|
||||
}}>
|
||||
);
|
||||
})}
|
||||
<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"}>
|
||||
{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)
|
||||
}}>
|
||||
<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) => {
|
||||
<TagInput
|
||||
onAdd={(tag) => {
|
||||
setTags((prev) => {
|
||||
const existingTag = prev.find(t => t.id === tag.id);
|
||||
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) => {
|
||||
<QuickAddTagDialog
|
||||
onAdded={(tags) => {
|
||||
setTags((prev) => {
|
||||
const newTags = [...prev];
|
||||
for (const tag of tags) {
|
||||
const existingTag = newTags.find(t => t.id === tag.id);
|
||||
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)} />
|
||||
<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>
|
||||
@@ -192,11 +232,17 @@ export default function EditResourcePage() {
|
||||
<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(
|
||||
"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" : ""}`}>
|
||||
<div
|
||||
className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}
|
||||
>
|
||||
<table className={"table"}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -206,52 +252,72 @@ export default function EditResourcePage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
images.map((image, index) => {
|
||||
return <tr key={index} className={"hover"}>
|
||||
{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"} />
|
||||
<img
|
||||
src={network.getImageUrl(image)}
|
||||
className={"w-16 h-16 object-cover card"}
|
||||
alt={"image"}
|
||||
/>
|
||||
</td>
|
||||
<td>{network.getImageUrl(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)
|
||||
}}>
|
||||
<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]));
|
||||
}}/>
|
||||
<SelectAndUploadImageButton
|
||||
onUploaded={(images) => {
|
||||
setImages((prev) => [...prev, ...images]);
|
||||
}}
|
||||
/>
|
||||
<span className={"w-4"}></span>
|
||||
<UploadClipboardImageButton onUploaded={(images) => {
|
||||
setImages((prev) => ([...prev, ...images]));
|
||||
}}/>
|
||||
<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" />
|
||||
{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>
|
||||
<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>}
|
||||
@@ -260,4 +326,5 @@ export default function EditResourcePage() {
|
||||
</div>
|
||||
</div>
|
||||
</ImageDrapArea>
|
||||
);
|
||||
}
|
||||
|
@@ -9,18 +9,18 @@ import {useAppContext} from "../components/AppContext.tsx";
|
||||
export default function HomePage() {
|
||||
useEffect(() => {
|
||||
document.title = app.appName;
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const {t} = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const appContext = useAppContext()
|
||||
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,9 +28,13 @@ export default function HomePage() {
|
||||
}
|
||||
}, [appContext, order]);
|
||||
|
||||
return <>
|
||||
return (
|
||||
<>
|
||||
<div className={"flex p-4 items-center"}>
|
||||
<select value={order} className="select w-52 select-info" onInput={(e) => {
|
||||
<select
|
||||
value={order}
|
||||
className="select w-52 select-info"
|
||||
onInput={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
if (value === "0") {
|
||||
setOrder(RSort.TimeAsc);
|
||||
@@ -45,7 +49,8 @@ export default function HomePage() {
|
||||
} 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>
|
||||
@@ -61,4 +66,5 @@ export default function HomePage() {
|
||||
loader={(page) => network.getResources(page, order)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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"}>
|
||||
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"/>
|
||||
{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>}
|
||||
</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)}/>
|
||||
<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)}/>
|
||||
<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={() => {
|
||||
<button
|
||||
className="btn"
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
navigate("/register", { replace: true });
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{t("Don't have an account? Register")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</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">
|
||||
return (
|
||||
<div className="px-2">
|
||||
<ChangeAvatarDialog />
|
||||
<ChangeUsernameDialog />
|
||||
<ChangePasswordDialog />
|
||||
<ChangeBioDialog />
|
||||
</div>;
|
||||
</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}>
|
||||
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="text-2xl">{icon}</span>
|
||||
<span className="ml-2">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeAvatarDialog() {
|
||||
@@ -73,28 +95,47 @@ 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;
|
||||
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
|
||||
className="w-28 rounded-full cursor-pointer"
|
||||
onClick={selectAvatar}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
avatar
|
||||
? URL.createObjectURL(avatar)
|
||||
: network.getUserAvatar(app.user!)
|
||||
}
|
||||
alt={"avatar"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,11 +144,19 @@ function ChangeAvatarDialog() {
|
||||
<form method="dialog">
|
||||
<Button>{t("Close")}</Button>
|
||||
</form>
|
||||
<Button className="btn-primary" onClick={handleSubmit} isLoading={isLoading} disabled={avatar == null}>{t("Save")}</Button>
|
||||
<Button
|
||||
className="btn-primary"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={avatar == null}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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,20 +195,25 @@ function ChangeUsernameDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<ListTile icon={<MdOutlineEditNote />} title={t("Change Username")} onClick={() => {
|
||||
const dialog = document.getElementById("change_username_dialog") as HTMLDialogElement;
|
||||
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>
|
||||
<label className="label">{t("New Username")}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("Enter new username")}
|
||||
@@ -181,7 +237,8 @@ function ChangeUsernameDialog() {
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePasswordDialog() {
|
||||
@@ -226,7 +283,9 @@ function ChangePasswordDialog() {
|
||||
type: "success",
|
||||
});
|
||||
|
||||
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement;
|
||||
const dialog = document.getElementById(
|
||||
"change_password_dialog",
|
||||
) as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
@@ -239,13 +298,20 @@ function ChangePasswordDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<ListTile icon={<MdLockOutline />} title={t("Change Password")} onClick={() => {
|
||||
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement;
|
||||
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>
|
||||
@@ -273,7 +339,9 @@ function ChangePasswordDialog() {
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("Confirm New Password")}</legend>
|
||||
<legend className="fieldset-legend">
|
||||
{t("Confirm New Password")}
|
||||
</legend>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("Confirm new password")}
|
||||
@@ -300,7 +368,8 @@ function ChangePasswordDialog() {
|
||||
</div>
|
||||
</div>
|
||||
</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,17 +409,28 @@ function ChangeBioDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<ListTile icon={<MdOutlineEditNote />} title={t("Change Bio")} onClick={() => {
|
||||
const dialog = document.getElementById("change_bio_dialog") as HTMLDialogElement;
|
||||
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"} />
|
||||
<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">
|
||||
@@ -365,5 +447,6 @@ function ChangeBioDialog() {
|
||||
</div>
|
||||
</div>
|
||||
</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={() => {
|
||||
return (
|
||||
<li
|
||||
key={title}
|
||||
onClick={() => {
|
||||
setPage(p);
|
||||
const checkbox = document.getElementById("my-drawer-2") as HTMLInputElement;
|
||||
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"}`}>
|
||||
}}
|
||||
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>
|
||||
<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">
|
||||
return (
|
||||
<div className="drawer lg:drawer-open">
|
||||
<input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
|
||||
<div className="drawer-content" style={{
|
||||
<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">
|
||||
<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>
|
||||
<h1 className={"text-xl font-bold"}>{pageNames[page]}</h1>
|
||||
</div>
|
||||
<div>
|
||||
{pageComponents[page]}
|
||||
<div>{pageComponents[page]}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="drawer-side" style={{
|
||||
<div
|
||||
className="drawer-side"
|
||||
style={{
|
||||
height: lg ? "calc(100vh - 64px)" : "100vh",
|
||||
}}>
|
||||
<label htmlFor="my-drawer-2" aria-label="close sidebar" className="drawer-overlay"></label>
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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("Storage"),
|
||||
<MdOutlineStorage className={"text-xl"} />,
|
||||
1,
|
||||
)}
|
||||
{buildItem(t("Users"), <MdOutlinePerson className={"text-xl"} />, 2)}
|
||||
{buildItem(t("Server"), <MdOutlineStorage className={"text-xl"} />, 3)}
|
||||
{buildItem(
|
||||
t("Server"),
|
||||
<MdOutlineStorage className={"text-xl"} />,
|
||||
3,
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
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";
|
||||
@@ -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>
|
||||
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 })
|
||||
}} />
|
||||
<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." />
|
||||
<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>
|
||||
<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,24 +78,36 @@ 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>
|
||||
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>
|
||||
<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" : ""}`}>
|
||||
<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>
|
||||
@@ -96,38 +118,57 @@ export default function StorageView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
storages.map((s) => {
|
||||
return <tr key={s.id} className={"hover"}>
|
||||
{storages.map((s) => {
|
||||
return (
|
||||
<tr key={s.id} className={"hover"}>
|
||||
<td>{s.name}</td>
|
||||
<td>{new Date(s.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
{s.name}
|
||||
{(s.currentSize / 1024 / 1024).toFixed(2)} /{" "}
|
||||
{s.maxSize / 1024 / 1024} MB
|
||||
</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;
|
||||
<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} />}
|
||||
}}
|
||||
>
|
||||
{loadingId === s.id ? (
|
||||
<span
|
||||
className={"loading loading-spinner loading-sm"}
|
||||
></span>
|
||||
) : (
|
||||
<MdDelete size={24} />
|
||||
)}
|
||||
</button>
|
||||
<dialog id={`confirm_delete_dialog_${s.id}`} className="modal">
|
||||
<dialog
|
||||
id={`confirm_delete_dialog_${s.id}`}
|
||||
className="modal"
|
||||
>
|
||||
<div className="modal-box">
|
||||
<h3 className="text-lg font-bold">{t("Delete Storage")}</h3>
|
||||
<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.")}
|
||||
{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={() => {
|
||||
<button
|
||||
className="btn btn-error"
|
||||
onClick={() => {
|
||||
handleDelete(s.id);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{t("Delete")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -135,8 +176,8 @@ export default function StorageView() {
|
||||
</dialog>
|
||||
</td>
|
||||
</tr>
|
||||
})
|
||||
}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -144,6 +185,7 @@ export default function StorageView() {
|
||||
<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,19 +259,27 @@ 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;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"new_storage_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<MdAdd />
|
||||
{t("New Storage")}
|
||||
</button>
|
||||
@@ -220,36 +289,63 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
|
||||
<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={() => {
|
||||
<input
|
||||
className="btn btn-square"
|
||||
type="reset"
|
||||
value="×"
|
||||
onClick={() => {
|
||||
setStorageType(null);
|
||||
}} />
|
||||
<input className="btn" type="radio" name="type" aria-label={t("Local")} onInput={() => {
|
||||
}}
|
||||
/>
|
||||
<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={() => {
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="btn"
|
||||
type="radio"
|
||||
name="type"
|
||||
aria-label={t("S3")}
|
||||
onInput={() => {
|
||||
setStorageType(StorageType.s3);
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{
|
||||
storageType === StorageType.local && <>
|
||||
{storageType === StorageType.local && (
|
||||
<>
|
||||
<label className="input w-full my-2">
|
||||
{t("Name")}
|
||||
<input type="text" className="w-full" value={params.name} onChange={(e) => {
|
||||
<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) => {
|
||||
<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)")}
|
||||
@@ -263,68 +359,99 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
setParams({
|
||||
...params,
|
||||
maxSizeInMB: parseInt(e.target.value),
|
||||
})
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
|
||||
{
|
||||
storageType === StorageType.s3 && <>
|
||||
{storageType === StorageType.s3 && (
|
||||
<>
|
||||
<label className="input w-full my-2">
|
||||
{t("Name")}
|
||||
<input type="text" className="w-full" value={params.name} onChange={(e) => {
|
||||
<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) => {
|
||||
<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) => {
|
||||
<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) => {
|
||||
<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) => {
|
||||
<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) => {
|
||||
<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)")}
|
||||
@@ -338,12 +465,12 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
setParams({
|
||||
...params,
|
||||
maxSizeInMB: parseInt(e.target.value),
|
||||
})
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
|
||||
{error !== "" && <ErrorAlert message={error} className={"my-2"} />}
|
||||
|
||||
@@ -351,12 +478,21 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
<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>}
|
||||
<button
|
||||
className={"btn btn-primary"}
|
||||
onClick={handleSubmit}
|
||||
type={"button"}
|
||||
>
|
||||
{isLoading && (
|
||||
<span
|
||||
className={"loading loading-spinner loading-sm mr-2"}
|
||||
></span>
|
||||
)}
|
||||
{t("Submit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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 <>
|
||||
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) => {
|
||||
<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;
|
||||
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" />
|
||||
<input
|
||||
type="search"
|
||||
className="grow"
|
||||
placeholder={t("Search")}
|
||||
id="search"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<UserTable page={page} searchKeyword={searchKeyword} key={`${page}&${searchKeyword}`} totalPagesCallback={setTotalPages} />
|
||||
<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}
|
||||
{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,7 +129,10 @@ 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`}>
|
||||
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>
|
||||
@@ -104,17 +144,16 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
users.map((u) => {
|
||||
return <UserRow key={u.id} user={u} onChanged={handleChanged} />
|
||||
})
|
||||
}
|
||||
{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,50 +262,80 @@ 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>
|
||||
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;
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="btn btn-square m-1"
|
||||
onClick={() => {
|
||||
showPopup(
|
||||
<ul className="menu bg-base-100 rounded-box z-1 w-64 p-2 shadow-sm">
|
||||
<h4 className="text-sm font-bold px-3 py-1 text-primary">
|
||||
{t("Actions")}
|
||||
</h4>
|
||||
<PopupMenuItem
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
`delete_user_dialog_${user.id}`,
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<a>{t("Delete")}</a>
|
||||
</PopupMenuItem>
|
||||
{user.is_admin ? <PopupMenuItem onClick={handleSetUser}><a>{t("Set as user")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetAdmin}><a>{t("Set as admin")}</a></PopupMenuItem>}
|
||||
{user.is_admin ? (
|
||||
<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>
|
||||
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" />}
|
||||
</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>
|
||||
<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>
|
||||
<button className="btn btn-error" onClick={handleDelete}>
|
||||
{t("Delete")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,4 +343,5 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
@@ -8,77 +8,94 @@ 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 {
|
||||
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]));
|
||||
}}>
|
||||
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">
|
||||
@@ -86,78 +103,106 @@ export default function PublishPage() {
|
||||
<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)} />
|
||||
<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)
|
||||
}}>
|
||||
{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, ""])
|
||||
}}>
|
||||
);
|
||||
})}
|
||||
<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"}>
|
||||
{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)
|
||||
}}>
|
||||
<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) => {
|
||||
<TagInput
|
||||
onAdd={(tag) => {
|
||||
setTags((prev) => {
|
||||
const existingTag = prev.find(t => t.id === tag.id);
|
||||
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) => {
|
||||
<QuickAddTagDialog
|
||||
onAdded={(tags) => {
|
||||
setTags((prev) => {
|
||||
const newTags = [...prev];
|
||||
for (const tag of tags) {
|
||||
const existingTag = newTags.find(t => t.id === tag.id);
|
||||
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)} />
|
||||
<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>
|
||||
@@ -167,11 +212,17 @@ export default function PublishPage() {
|
||||
<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(
|
||||
"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" : ""}`}>
|
||||
<div
|
||||
className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}
|
||||
>
|
||||
<table className={"table"}>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -181,52 +232,72 @@ export default function PublishPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
images.map((image, index) => {
|
||||
return <tr key={index} className={"hover"}>
|
||||
{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"} />
|
||||
<img
|
||||
src={network.getImageUrl(image)}
|
||||
className={"w-16 h-16 object-cover card"}
|
||||
alt={"image"}
|
||||
/>
|
||||
</td>
|
||||
<td>{network.getImageUrl(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)
|
||||
}}>
|
||||
<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]));
|
||||
}}/>
|
||||
<SelectAndUploadImageButton
|
||||
onUploaded={(images) => {
|
||||
setImages((prev) => [...prev, ...images]);
|
||||
}}
|
||||
/>
|
||||
<span className={"w-4"}></span>
|
||||
<UploadClipboardImageButton onUploaded={(images) => {
|
||||
setImages((prev) => ([...prev, ...images]));
|
||||
}}/>
|
||||
<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" />
|
||||
{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>
|
||||
<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>}
|
||||
@@ -235,4 +306,5 @@ export default function PublishPage() {
|
||||
</div>
|
||||
</div>
|
||||
</ImageDrapArea>
|
||||
);
|
||||
}
|
@@ -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"}>
|
||||
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"/>
|
||||
{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>}
|
||||
</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)}/>
|
||||
<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)}/>
|
||||
<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)}/>
|
||||
<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
|
||||
{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={() => {
|
||||
<button
|
||||
className="btn"
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
navigate("/login", { replace: true });
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{t("Already have an account? Login")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -5,23 +5,31 @@ 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">
|
||||
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>
|
||||
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>
|
||||
);
|
||||
}
|
@@ -13,7 +13,7 @@ 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"}>
|
||||
return (
|
||||
<div className={"m-4"}>
|
||||
<ErrorAlert message={t("Tag not found")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <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)
|
||||
}} />
|
||||
}
|
||||
{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>}
|
||||
{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>
|
||||
})
|
||||
}
|
||||
{(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>
|
||||
{tag?.description && (
|
||||
<article className={"px-4 py-2"}>
|
||||
<Markdown>{tag.description}</Markdown>
|
||||
</article>
|
||||
}
|
||||
<ResourcesView loader={(page) => {
|
||||
return network.getResourcesByTag(tagName, page)
|
||||
}}></ResourcesView>
|
||||
)}
|
||||
<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;
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"edit_tag_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>{t("Edit")}</Button>
|
||||
}}
|
||||
>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
<dialog id="edit_tag_dialog" className="modal">
|
||||
<div className="modal-box" style={{
|
||||
overflowY: "initial"
|
||||
}}>
|
||||
<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) => {
|
||||
<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) => {
|
||||
)}
|
||||
<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)}/>
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
) : (
|
||||
<>
|
||||
<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}>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className={"btn-primary"}
|
||||
onClick={submit}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -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">
|
||||
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>
|
||||
<h2 className="text-lg font-bold pl-1">
|
||||
{type == "" ? "Other" : type}
|
||||
</h2>
|
||||
<p>
|
||||
{tags.map(tag => (
|
||||
<Badge onClick={() => {
|
||||
{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})` : "")}
|
||||
}}
|
||||
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,27 +33,44 @@ export default function UserPage() {
|
||||
}, [username]);
|
||||
|
||||
if (!user) {
|
||||
return <div className="w-full">
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Loading />
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <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
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCard({ user }: { user: User }) {
|
||||
return <div className={"flex m-4 items-center"}>
|
||||
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)} />
|
||||
@@ -63,24 +80,36 @@ function UserCard({ user }: { user: User }) {
|
||||
<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>
|
||||
{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-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 (
|
||||
<ResourcesView
|
||||
loader={(page) => {
|
||||
return network.getResourcesByUser(user.username, page);
|
||||
}}></ResourcesView>
|
||||
}}
|
||||
></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"}>
|
||||
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>
|
||||
) : 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,24 +159,27 @@ function CommentsList({ username, page, maxPageCallback }: {
|
||||
}, [maxPageCallback, page, username]);
|
||||
|
||||
if (comments == null) {
|
||||
return <div className={"w-full"}>
|
||||
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"}>
|
||||
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">
|
||||
@@ -145,16 +189,20 @@ function CommentTile({ comment }: { comment: CommentWithResource }) {
|
||||
<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={"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={() => {
|
||||
<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>
|
||||
);
|
||||
}
|
@@ -1,16 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(),],
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
"/api": {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
Reference in New Issue
Block a user