This commit is contained in:
2025-06-04 10:20:01 +08:00
parent 7994ecc100
commit ad1144ad69
42 changed files with 5536 additions and 3740 deletions

View File

@@ -24,31 +24,31 @@ export default tseslint.config({
languageOptions: { languageOptions: {
// other options... // other options...
parserOptions: { parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'], project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname, 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: 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 ```js
// eslint.config.js // eslint.config.js
import reactX from 'eslint-plugin-react-x' import reactX from "eslint-plugin-react-x";
import reactDom from 'eslint-plugin-react-dom' import reactDom from "eslint-plugin-react-dom";
export default tseslint.config({ export default tseslint.config({
plugins: { plugins: {
// Add the react-x and react-dom plugins // Add the react-x and react-dom plugins
'react-x': reactX, "react-x": reactX,
'react-dom': reactDom, "react-dom": reactDom,
}, },
rules: { rules: {
// other rules... // other rules...
// Enable its recommended typescript rules // Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules, ...reactX.configs["recommended-typescript"].rules,
...reactDom.configs.recommended.rules, ...reactDom.configs.recommended.rules,
}, },
}) });
``` ```

View File

@@ -1,33 +1,33 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<base href="/"> <base href="/" />
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible" />
<meta name="description" content="{{Description}}"> <meta name="description" content="{{Description}}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO meta --> <!-- SEO meta -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{Title}}"> <meta name="twitter:title" content="{{Title}}" />
<meta name="twitter:description" content="{{Description}}"> <meta name="twitter:description" content="{{Description}}" />
<meta name="twitter:image" content="{{Preview}}"> <meta name="twitter:image" content="{{Preview}}" />
<meta property="og:title" content="{{Title}}"> <meta property="og:title" content="{{Title}}" />
<meta property="og:type" content="website"> <meta property="og:type" content="website" />
<meta property="og:url" content="{{Url}}"> <meta property="og:url" content="{{Url}}" />
<meta property="og:image" content="{{Preview}}"> <meta property="og:image" content="{{Preview}}" />
<meta property="og:description" content="{{Description}}"> <meta property="og:description" content="{{Description}}" />
<meta property="og:site_name" content={{SiteName}}> <meta property="og:site_name" content="{{SiteName}}" />
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes"> <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-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Nysoure"> <meta name="apple-mobile-web-app-title" content="Nysoure" />
<link rel="apple-touch-icon" href="/icon-192.png"> <link rel="apple-touch-icon" href="/icon-192.png" />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" href="/favicon.ico" type="image/x-icon"> <link rel="icon" href="/favicon.ico" type="image/x-icon" />
<title>{{Title}}</title> <title>{{Title}}</title>
</head> </head>

View File

@@ -1,4 +1,4 @@
import {User} from "./network/models.ts"; import { User } from "./network/models.ts";
interface MyWindow extends Window { interface MyWindow extends Window {
serverName?: string; serverName?: string;
@@ -7,7 +7,7 @@ interface MyWindow extends Window {
} }
class App { class App {
appName = "Nysoure" appName = "Nysoure";
user: User | null = null; user: User | null = null;
@@ -15,7 +15,7 @@ class App {
cloudflareTurnstileSiteKey: string | null = null; cloudflareTurnstileSiteKey: string | null = null;
siteInfo = "" siteInfo = "";
constructor() { constructor() {
this.init(); this.init();
@@ -31,7 +31,8 @@ class App {
this.token = JSON.parse(tokenJson); this.token = JSON.parse(tokenJson);
} }
this.appName = (window as MyWindow).serverName || this.appName; 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 || ""; this.siteInfo = (window as MyWindow).siteInfo || "";
} }

View File

@@ -1,4 +1,4 @@
import {BrowserRouter, Route, Routes} from "react-router"; import { BrowserRouter, Route, Routes } from "react-router";
import LoginPage from "./pages/login_page.tsx"; import LoginPage from "./pages/login_page.tsx";
import RegisterPage from "./pages/register_page.tsx"; import RegisterPage from "./pages/register_page.tsx";
import Navigator from "./components/navigator.tsx"; import Navigator from "./components/navigator.tsx";
@@ -17,21 +17,21 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path={"/login"} element={<LoginPage/>}/> <Route path={"/login"} element={<LoginPage />} />
<Route path={"/register"} element={<RegisterPage/>}/> <Route path={"/register"} element={<RegisterPage />} />
<Route element={<Navigator/>}> <Route element={<Navigator />}>
<Route path={"/"} element={<HomePage/>}/> <Route path={"/"} element={<HomePage />} />
<Route path={"/publish"} element={<PublishPage/>} /> <Route path={"/publish"} element={<PublishPage />} />
<Route path={"/search"} element={<SearchPage/>} /> <Route path={"/search"} element={<SearchPage />} />
<Route path={"/resources/:id"} element={<ResourcePage/>}/> <Route path={"/resources/:id"} element={<ResourcePage />} />
<Route path={"/manage"} element={<ManagePage/>}/> <Route path={"/manage"} element={<ManagePage />} />
<Route path={"/tag/:tag"} element={<TaggedResourcesPage/>}/> <Route path={"/tag/:tag"} element={<TaggedResourcesPage />} />
<Route path={"/user/:username"} element={<UserPage/>}/> <Route path={"/user/:username"} element={<UserPage />} />
<Route path={"/resource/edit/:rid"} element={<EditResourcePage/>}/> <Route path={"/resource/edit/:rid"} element={<EditResourcePage />} />
<Route path={"/about"} element={<AboutPage/>}/> <Route path={"/about"} element={<AboutPage />} />
<Route path={"/tags"} element={<TagsPage/>}/> <Route path={"/tags"} element={<TagsPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
) );
} }

View File

@@ -1,10 +1,6 @@
import {createContext, ReactNode, useContext} from "react"; import { createContext, ReactNode, useContext } from "react";
export default function AppContext({ export default function AppContext({ children }: { children: ReactNode }) {
children,
}: {
children: ReactNode;
}) {
return ( return (
<context.Provider value={new Map<string, any>()}> <context.Provider value={new Map<string, any>()}>
{children} {children}
@@ -15,5 +11,5 @@ export default function AppContext({
const context = createContext<Map<string, any>>(new Map<string, any>()); const context = createContext<Map<string, any>>(new Map<string, any>());
export function useAppContext() { export function useAppContext() {
return useContext(context) return useContext(context);
} }

View File

@@ -1,18 +1,53 @@
export function ErrorAlert({ message, className }: { message: string, className?: string }) { export function ErrorAlert({
return <div role="alert" className={`alert alert-error ${className}`}> message,
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24"> className,
<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" /> 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> </svg>
<span>{message}</span> <span>{message}</span>
</div>; </div>
);
} }
export function InfoAlert({ message, className }: { message: string, className?: string }) { export function InfoAlert({
return <div role="alert" className={`alert alert-info ${className}`}> message,
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-6 w-6 shrink-0 stroke-current"> className,
<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> }: {
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> </svg>
<span>{message}</span> <span>{message}</span>
</div>; </div>
);
} }

View File

@@ -1,9 +1,39 @@
import {ReactNode} from "react"; import { ReactNode } from "react";
export default function Badge({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) { export default function Badge({
return <span className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`} onClick={onClick}>{children}</span> 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 }) { export function BadgeAccent({
return <span className={`badge badge-accent text-sm ${className}`} onClick={onClick}>{children}</span> children,
className,
onClick,
}: {
children: ReactNode;
className?: string;
onClick?: () => void;
}) {
return (
<span
className={`badge badge-accent text-sm ${className}`}
onClick={onClick}
>
{children}
</span>
);
} }

View File

@@ -1,14 +1,28 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
export default function Button({ children, onClick, className, disabled, isLoading }: { children: ReactNode, onClick?: () => void, className?: string, disabled?: boolean, isLoading?: boolean }) { export default function Button({
return <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`} className={`btn ${className} ${disabled ? "btn-disabled" : ""} h-9`}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
> >
{isLoading && <span className="loading loading-spinner loading-sm mr-2"></span>} {isLoading && (
<span className="text-sm"> <span className="loading loading-spinner loading-sm mr-2"></span>
{children} )}
</span> <span className="text-sm">{children}</span>
</button>; </button>
);
} }

View File

@@ -1,8 +1,8 @@
import {MdAdd} from "react-icons/md"; import { MdAdd } from "react-icons/md";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {network} from "../network/network.ts"; import { network } from "../network/network.ts";
import showToast from "./toast.ts"; import showToast from "./toast.ts";
import {useState} from "react"; import { useState } from "react";
async function uploadImages(files: File[]): Promise<number[]> { async function uploadImages(files: File[]): Promise<number[]> {
const images: number[] = []; const images: number[] = [];
@@ -15,65 +15,83 @@ async function uploadImages(files: File[]): Promise<number[]> {
showToast({ showToast({
type: "error", type: "error",
message: `Failed to upload image: ${res.message}`, message: `Failed to upload image: ${res.message}`,
}) });
} }
} }
return images; return images;
} }
export function SelectAndUploadImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) { export function SelectAndUploadImageButton({
const [isUploading, setUploading] = useState(false) onUploaded,
}: {
onUploaded: (image: number[]) => void;
}) {
const [isUploading, setUploading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const addImage = () => { const addImage = () => {
if (isUploading) { if (isUploading) {
return return;
} }
const input = document.createElement("input") const input = document.createElement("input");
input.type = "file" input.type = "file";
input.accept = "image/*" input.accept = "image/*";
input.multiple = true input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (!input.files || input.files.length === 0) { if (!input.files || input.files.length === 0) {
return return;
} }
setUploading(true) setUploading(true);
const files = Array.from(input.files); const files = Array.from(input.files);
const uploadedImages = await uploadImages(files); const uploadedImages = await uploadImages(files);
setUploading(false); setUploading(false);
if (uploadedImages.length > 0) { if (uploadedImages.length > 0) {
onUploaded(uploadedImages); onUploaded(uploadedImages);
} }
} };
input.click() input.click();
} };
return <button className={"btn my-2"} type={"button"} onClick={addImage}> return (
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />} <button className={"btn my-2"} type={"button"} onClick={addImage}>
{isUploading ? (
<span className="loading loading-spinner"></span>
) : (
<MdAdd />
)}
{t("Upload Image")} {t("Upload Image")}
</button> </button>
);
} }
export function UploadClipboardImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) { export function UploadClipboardImageButton({
const [isUploading, setUploading] = useState(false) onUploaded,
}: {
onUploaded: (image: number[]) => void;
}) {
const [isUploading, setUploading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const addClipboardImage = async () => { const addClipboardImage = async () => {
if (isUploading) { if (isUploading) {
return return;
} }
try { try {
const clipboardItems = await navigator.clipboard.read(); const clipboardItems = await navigator.clipboard.read();
const files: File[] = []; const files: File[] = [];
for (const item of clipboardItems) { for (const item of clipboardItems) {
console.log(item) console.log(item);
for (const type of item.types) { for (const type of item.types) {
if (type.startsWith("image/")) { if (type.startsWith("image/")) {
const blob = await item.getType(type); 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"), message: t("Failed to read clipboard image"),
}); });
} }
} };
return <button className={"btn my-2"} type={"button"} onClick={addClipboardImage}> return (
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />} <button className={"btn my-2"} type={"button"} onClick={addClipboardImage}>
{isUploading ? (
<span className="loading loading-spinner"></span>
) : (
<MdAdd />
)}
{t("Upload Clipboard Image")} {t("Upload Clipboard Image")}
</button> </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 [isUploading, setUploading] = useState(false);
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
@@ -128,7 +158,7 @@ export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode
if (e.dataTransfer.files.length > 0) { if (e.dataTransfer.files.length > 0) {
setUploading(true); setUploading(true);
let files = Array.from(e.dataTransfer.files); 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) { if (files.length === 0) {
setUploading(false); setUploading(false);
return; return;

View File

@@ -11,15 +11,31 @@ interface InputProps {
export default function Input(props: InputProps) { export default function Input(props: InputProps) {
if (props.inlineLabel) { if (props.inlineLabel) {
return <label className="input w-full"> return (
<label className="input w-full">
{props.label} {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> </label>
);
} else { } else {
return <fieldset className="fieldset w-full"> return (
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{props.label}</legend> <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> </fieldset>
);
} }
} }
@@ -31,10 +47,17 @@ interface TextAreaProps {
} }
export function TextArea(props: 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> <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, height: props.height,
}} /> }}
/>
</fieldset> </fieldset>
);
} }

View File

@@ -1,10 +1,12 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function Loading() { export default function Loading() {
const {t} = useTranslation(); 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 className="loading loading-spinner progress-primary loading-lg mr-2"></span>
<span>{t("Loading")}</span> <span>{t("Loading")}</span>
</div>; </div>
);
} }

View File

@@ -2,16 +2,21 @@ import { app } from "../app.ts";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import { useNavigate, useOutlet } from "react-router"; import { useNavigate, useOutlet } from "react-router";
import { createContext, useContext, useEffect, useState } from "react"; 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 { useTranslation } from "react-i18next";
import UploadingSideBar from "./uploading_side_bar.tsx"; import UploadingSideBar from "./uploading_side_bar.tsx";
import { IoLogoGithub } from "react-icons/io"; import { IoLogoGithub } from "react-icons/io";
import {useAppContext} from "./AppContext.tsx"; import { useAppContext } from "./AppContext.tsx";
export default function Navigator() { export default function Navigator() {
const outlet = useOutlet() const outlet = useOutlet();
const navigate = useNavigate() const navigate = useNavigate();
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
@@ -25,86 +30,154 @@ export default function Navigator() {
const { t } = useTranslation(); const { t } = useTranslation();
return <> return (
<FloatingToTopButton/> <>
<div className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10" key={key}> <FloatingToTopButton />
<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={"flex-1 max-w-7xl mx-auto flex items-center"}>
<div className="dropdown"> <div className="dropdown">
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle lg:hidden"> <div
<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} tabIndex={0}
className="menu menu-md dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"> role="button"
<li onClick={() => { className="btn btn-ghost btn-circle lg:hidden"
const menu = document.getElementById("navi_menu") as HTMLElement; >
<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(); menu.blur();
navigate("/"); 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(); menu.blur();
navigate("/tags") navigate("/tags");
}}><a>{t("Tags")}</a></li> }}
<li onClick={() => { >
const menu = document.getElementById("navi_menu") as HTMLElement; <a>{t("Tags")}</a>
</li>
<li
onClick={() => {
const menu = document.getElementById(
"navi_menu",
) as HTMLElement;
menu.blur(); menu.blur();
navigate("/about") navigate("/about");
}}><a>{t("About")}</a></li> }}
>
<a>{t("About")}</a>
</li>
</ul> </ul>
</div> </div>
<div> <div>
<button className="btn btn-ghost text-xl" onClick={() => { <button
appContext.clear() className="btn btn-ghost text-xl"
navigate(`/`, { replace: true}); onClick={() => {
}}>{app.appName}</button> appContext.clear();
navigate(`/`, { replace: true });
}}
>
{app.appName}
</button>
</div> </div>
<div className="hidden lg:flex"> <div className="hidden lg:flex">
<ul className="menu menu-horizontal px-1"> <ul className="menu menu-horizontal px-1">
<li onClick={() => { <li
onClick={() => {
navigate("/"); navigate("/");
}}><a>{t("Home")}</a></li> }}
<li onClick={() => { >
navigate("/tags") <a>{t("Home")}</a>
}}><a>{t("Tags")}</a></li> </li>
<li onClick={() => { <li
navigate("/about") onClick={() => {
}}><a>{t("About")}</a></li> navigate("/tags");
}}
>
<a>{t("Tags")}</a>
</li>
<li
onClick={() => {
navigate("/about");
}}
>
<a>{t("About")}</a>
</li>
</ul> </ul>
</div> </div>
<div className={"flex-1"}></div> <div className={"flex-1"}></div>
<div className="flex gap-2"> <div className="flex gap-2">
<SearchBar/> <SearchBar />
<UploadingSideBar/> <UploadingSideBar />
{ {app.isLoggedIn() && (
app.isLoggedIn() && <button className={"btn btn-circle btn-ghost"} onClick={() => { <button
className={"btn btn-circle btn-ghost"}
onClick={() => {
navigate("/manage"); navigate("/manage");
}}> }}
<MdSettings size={24}/> >
<MdSettings size={24} />
</button> </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"); window.open("https://github.com/wgh136/nysoure", "_blank");
}}> }}
<IoLogoGithub size={24}/> >
<IoLogoGithub size={24} />
</button> </button>
{ {app.isLoggedIn() ? (
app.isLoggedIn() ? <UserButton/> : <UserButton />
<button className={"btn btn-primary btn-square btn-soft"} onClick={() => { ) : (
<button
className={"btn btn-primary btn-square btn-soft"}
onClick={() => {
navigate("/login"); navigate("/login");
}}> }}
>
<MdOutlinePerson size={24}></MdOutlinePerson> <MdOutlinePerson size={24}></MdOutlinePerson>
</button> </button>
} )}
</div> </div>
</div> </div>
</div> </div>
<navigatorContext.Provider value={naviContext}> <navigatorContext.Provider value={naviContext}>
<div className={"max-w-7xl mx-auto pt-16"}> <div className={"max-w-7xl mx-auto pt-16"}>{outlet}</div>
{outlet}
</div>
</navigatorContext.Provider> </navigatorContext.Provider>
</> </>
);
} }
interface NavigatorContext { interface NavigatorContext {
@@ -114,8 +187,8 @@ interface NavigatorContext {
const navigatorContext = createContext<NavigatorContext>({ const navigatorContext = createContext<NavigatorContext>({
refresh: () => { refresh: () => {
// do nothing // do nothing
} },
}) });
export function useNavigator() { export function useNavigator() {
return useContext(navigatorContext); return useContext(navigatorContext);
@@ -124,40 +197,68 @@ export function useNavigator() {
function UserButton() { function UserButton() {
let avatar = "./avatar.png"; let avatar = "./avatar.png";
if (app.user) { 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 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"> <div className="w-10 rounded-full">
<img <img alt="Avatar" src={avatar} />
alt="Avatar"
src={avatar} />
</div> </div>
</div> </div>
<ul <ul
id={"navi_dropdown_menu"} id={"navi_dropdown_menu"}
tabIndex={0} tabIndex={0}
className="menu dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"> className="menu dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
<li><a onClick={() => { >
<li>
<a
onClick={() => {
navigate(`/user/${app.user?.username}`); 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(); menu.blur();
}}>{t("My Profile")}</a></li> }}
<li><a onClick={() => { >
{t("My Profile")}
</a>
</li>
<li>
<a
onClick={() => {
navigate(`/publish`); navigate(`/publish`);
const menu = document.getElementById("navi_dropdown_menu") as HTMLUListElement; const menu = document.getElementById(
"navi_dropdown_menu",
) as HTMLUListElement;
menu.blur(); 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(); dialog.showModal();
}}>{t("Log out")}</a></li> }}
>
{t("Log out")}
</a>
</li>
</ul> </ul>
</div> </div>
<dialog id="confirm_logout" className="modal"> <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> <p className="py-4">{t("Are you sure you want to log out?")}</p>
<div className="modal-action"> <div className="modal-action">
<form method="dialog"> <form method="dialog">
<button className="btn">{t('Cancel')}</button> <button className="btn">{t("Cancel")}</button>
<button className="btn btn-error mx-2" type={"button"} onClick={() => { <button
className="btn btn-error mx-2"
type={"button"}
onClick={() => {
app.user = null; app.user = null;
app.token = null; app.token = null;
app.saveData(); app.saveData();
navigate(`/login`, { replace: true }); navigate(`/login`, { replace: true });
}}>{t('Confirm')} }}
>
{t("Confirm")}
</button> </button>
</form> </form>
</div> </div>
</div> </div>
</dialog> </dialog>
</> </>
);
} }
function SearchBar() { function SearchBar() {
@@ -212,10 +319,15 @@ function SearchBar() {
} }
const replace = window.location.pathname === "/search"; const replace = window.location.pathname === "/search";
navigate(`/search?keyword=${search}`, { replace: replace }); navigate(`/search?keyword=${search}`, { replace: replace });
} };
const searchField = <label className={`input input-primary ${small ? "w-full" : "w-64"}`}> const searchField = (
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <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 <g
stroke-linejoin="round" stroke-linejoin="round"
stroke-linecap="round" stroke-linecap="round"
@@ -227,49 +339,74 @@ function SearchBar() {
<path d="m21 21-4.3-4.3"></path> <path d="m21 21-4.3-4.3"></path>
</g> </g>
</svg> </svg>
<form className={"w-full"} onSubmit={(e) => { <form
className={"w-full"}
onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
doSearch(); 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> </form>
</label> </label>
);
if (small) { if (small) {
return <> return (
<button className={"btn btn-circle btn-ghost"} onClick={() => { <>
const dialog = document.getElementById("search_dialog") as HTMLDialogElement; <button
className={"btn btn-circle btn-ghost"}
onClick={() => {
const dialog = document.getElementById(
"search_dialog",
) as HTMLDialogElement;
dialog.showModal(); dialog.showModal();
}}> }}
>
<MdSearch size={24} /> <MdSearch size={24} />
</button> </button>
<dialog id="search_dialog" className="modal"> <dialog id="search_dialog" className="modal">
<div className="modal-box"> <div className="modal-box">
<form method="dialog"> <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> </form>
<h3 className="text-lg font-bold">{t("Search")}</h3> <h3 className="text-lg font-bold">{t("Search")}</h3>
<div className={"h-4"} /> <div className={"h-4"} />
{searchField} {searchField}
<div className={"h-4"} /> <div className={"h-4"} />
<div className={"flex flex-row-reverse"}> <div className={"flex flex-row-reverse"}>
<button className={"btn btn-primary"} onClick={() => { <button
className={"btn btn-primary"}
onClick={() => {
if (search.length === 0) { if (search.length === 0) {
return; return;
} }
const dialog = document.getElementById("search_dialog") as HTMLDialogElement; const dialog = document.getElementById(
"search_dialog",
) as HTMLDialogElement;
dialog.close(); dialog.close();
doSearch(); doSearch();
}}> }}
>
{t("Search")} {t("Search")}
</button> </button>
</div> </div>
</div> </div>
</dialog> </dialog>
</> </>
);
} }
return searchField return searchField;
} }
function FloatingToTopButton() { 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" }); window.scrollTo({ top: 0, behavior: "smooth" });
}}> }}
<MdArrowUpward size={20}/> >
</button>; <MdArrowUpward size={20} />
</button>
);
} }

View File

@@ -1,48 +1,106 @@
import {ReactNode} from "react"; import { ReactNode } from "react";
import {MdChevronLeft, MdChevronRight} from "react-icons/md"; import { MdChevronLeft, MdChevronRight } from "react-icons/md";
export default function Pagination({page, setPage, totalPages}: { export default function Pagination({
page: number, page,
setPage: (page: number) => void, setPage,
totalPages: number totalPages,
}: {
page: number;
setPage: (page: number) => void;
totalPages: number;
}) { }) {
const items: ReactNode[] = []; const items: ReactNode[] = [];
if (page > 1) { 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) { 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) { 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) { 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) { 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) { 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"> return (
<button key={"btn-prev"} className={`join-item btn`} onClick={() => { <div className="join shadow rounded-field">
<button
key={"btn-prev"}
className={`join-item btn`}
onClick={() => {
if (page > 1) { if (page > 1) {
setPage(page - 1); setPage(page - 1);
} }
}}> }}
<MdChevronLeft size={20} className="opacity-50"/> >
<MdChevronLeft size={20} className="opacity-50" />
</button> </button>
{items} {items}
<button key={"btn-next"} className={`join-item btn`} onClick={() => { <button
key={"btn-next"}
className={`join-item btn`}
onClick={() => {
if (page < totalPages) { if (page < totalPages) {
setPage(page + 1); setPage(page + 1);
} }
}}> }}
<MdChevronRight size={20} className="opacity-50"/> >
<MdChevronRight size={20} className="opacity-50" />
</button> </button>
</div> </div>
);
} }

View File

@@ -1,7 +1,10 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; 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 eRect = element.getBoundingClientRect();
const div = document.createElement("div"); const div = document.createElement("div");
@@ -39,23 +42,33 @@ export default function showPopup(content: React.ReactNode, element: HTMLElement
mask.onclick = close; mask.onclick = close;
document.body.appendChild(mask); document.body.appendChild(mask);
createRoot(div).render(<context.Provider value={close}> createRoot(div).render(
{content} <context.Provider value={close}>{content}</context.Provider>,
</context.Provider>) );
} }
const context = React.createContext<() => void>(() => { }); const context = React.createContext<() => void>(() => {});
export function useClosePopup() { export function useClosePopup() {
return React.useContext(context); 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(); const close = useClosePopup();
return <li onClick={() => { return (
<li
onClick={() => {
close(); close();
onClick(); onClick();
}}> }}
>
{children} {children}
</li> </li>
);
} }

View File

@@ -4,36 +4,44 @@ import { useNavigate } from "react-router";
import Badge from "./badge.tsx"; import Badge from "./badge.tsx";
export default function ResourceCard({ resource }: { resource: Resource }) { 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) { if (tags.length > 10) {
tags = tags.slice(0, 10) tags = tags.slice(0, 10);
} }
return <div className={"p-2 cursor-pointer"} onClick={() => { return (
navigate(`/resources/${resource.id}`) <div
}}> className={"p-2 cursor-pointer"}
onClick={() => {
navigate(`/resources/${resource.id}`);
}}
>
<div className={"card shadow hover:shadow-md transition-shadow"}> <div className={"card shadow hover:shadow-md transition-shadow"}>
{ {resource.image != null && (
resource.image != null && <figure> <figure>
<img <img
src={network.getImageUrl(resource.image.id)} src={network.getImageUrl(resource.image.id)}
alt="cover" style={{ alt="cover"
style={{
width: "100%", width: "100%",
aspectRatio: resource.image.width / resource.image.height, aspectRatio: resource.image.width / resource.image.height,
}}/> }}
/>
</figure> </figure>
} )}
<div className="flex flex-col p-4"> <div className="flex flex-col p-4">
<h2 className="card-title">{resource.title}</h2> <h2 className="card-title">{resource.title}</h2>
<div className="h-2"></div> <div className="h-2"></div>
<p> <p>
{ {tags.map((tag) => {
tags.map((tag) => { return (
return <Badge key={tag.id} className={"m-0.5"}>{tag.name}</Badge> <Badge key={tag.id} className={"m-0.5"}>
}) {tag.name}
} </Badge>
);
})}
</p> </p>
<div className="h-2"></div> <div className="h-2"></div>
<div className="flex items-center"> <div className="flex items-center">
@@ -48,4 +56,5 @@ export default function ResourceCard({ resource }: { resource: Resource }) {
</div> </div>
</div> </div>
</div> </div>
);
} }

View File

@@ -1,69 +1,80 @@
import {PageResponse, Resource} from "../network/models.ts"; import { PageResponse, Resource } from "../network/models.ts";
import {useCallback, useEffect, useRef, useState} from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import showToast from "./toast.ts"; import showToast from "./toast.ts";
import ResourceCard from "./resource_card.tsx"; import ResourceCard from "./resource_card.tsx";
import {Masonry, useInfiniteLoader} from "masonic"; import { Masonry, useInfiniteLoader } from "masonic";
import Loading from "./loading.tsx"; import Loading from "./loading.tsx";
import {useAppContext} from "./AppContext.tsx"; import { useAppContext } from "./AppContext.tsx";
export default function ResourcesView({loader, storageKey}: {loader: (page: number) => Promise<PageResponse<Resource>>, storageKey?: string}) { export default function ResourcesView({
const [data, setData] = useState<Resource[]>([]) loader,
const pageRef = useRef(1) storageKey,
const totalPagesRef = useRef(1) }: {
const isLoadingRef = useRef(false) 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(() => { useEffect(() => {
if (storageKey) { if (storageKey) {
const data = appContext.get(storageKey + "/data") const data = appContext.get(storageKey + "/data");
const page = appContext.get(storageKey + "/page") const page = appContext.get(storageKey + "/page");
const totalPages = appContext.get(storageKey + "/totalPages") const totalPages = appContext.get(storageKey + "/totalPages");
console.log("loading data", data, page, totalPages) console.log("loading data", data, page, totalPages);
if (data) { if (data) {
setData(data) setData(data);
pageRef.current = page pageRef.current = page;
totalPagesRef.current = totalPages totalPagesRef.current = totalPages;
} }
} }
}, [appContext, storageKey]); }, [appContext, storageKey]);
useEffect(() => { useEffect(() => {
if (storageKey && data.length > 0) { if (storageKey && data.length > 0) {
console.log("storing data", data) console.log("storing data", data);
appContext.set(storageKey + "/data", data) appContext.set(storageKey + "/data", data);
appContext.set(storageKey + "/page", pageRef.current) appContext.set(storageKey + "/page", pageRef.current);
appContext.set(storageKey + "/totalPages", totalPagesRef.current) appContext.set(storageKey + "/totalPages", totalPagesRef.current);
} }
}, [appContext, data, storageKey]); }, [appContext, data, storageKey]);
const loadPage = useCallback(async () => { const loadPage = useCallback(async () => {
if (pageRef.current > totalPagesRef.current) return if (pageRef.current > totalPagesRef.current) return;
if (isLoadingRef.current) return if (isLoadingRef.current) return;
isLoadingRef.current = true isLoadingRef.current = true;
const res = await loader(pageRef.current) const res = await loader(pageRef.current);
if (!res.success) { if (!res.success) {
showToast({message: res.message, type: "error"}) showToast({ message: res.message, type: "error" });
} else { } else {
isLoadingRef.current = false isLoadingRef.current = false;
pageRef.current = pageRef.current + 1 pageRef.current = pageRef.current + 1;
totalPagesRef.current = res.totalPages ?? 1 totalPagesRef.current = res.totalPages ?? 1;
setData((prev) => [...prev, ...res.data!]) setData((prev) => [...prev, ...res.data!]);
} }
}, [loader]) }, [loader]);
useEffect(() => { useEffect(() => {
loadPage() loadPage();
}, [loadPage]); }, [loadPage]);
const maybeLoadMore = useInfiniteLoader(loadPage) const maybeLoadMore = useInfiniteLoader(loadPage);
return <div className={"px-2 pt-2"}> return (
<Masonry onRender={maybeLoadMore} columnWidth={300} items={data} render={(e) => { <div className={"px-2 pt-2"}>
return <ResourceCard resource={e.data} key={e.data.id}/> <Masonry
} }></Masonry> onRender={maybeLoadMore}
{ columnWidth={300}
pageRef.current <= totalPagesRef.current && <Loading/> items={data}
} render={(e) => {
return <ResourceCard resource={e.data} key={e.data.id} />;
}}
></Masonry>
{pageRef.current <= totalPagesRef.current && <Loading />}
</div> </div>
);
} }

View File

@@ -1,229 +1,332 @@
import {Tag} from "../network/models.ts"; import { Tag } from "../network/models.ts";
import {useRef, useState} from "react"; import { useRef, useState } from "react";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {network} from "../network/network.ts"; import { network } from "../network/network.ts";
import {LuInfo} from "react-icons/lu"; import { LuInfo } from "react-icons/lu";
import {MdSearch} from "react-icons/md"; import { MdSearch } from "react-icons/md";
import Button from "./button.tsx"; import Button from "./button.tsx";
import Input, {TextArea} from "./input.tsx"; import Input, { TextArea } from "./input.tsx";
import {ErrorAlert} from "./alert.tsx"; import { ErrorAlert } from "./alert.tsx";
export default function TagInput({ onAdd, mainTag }: { onAdd: (tag: Tag) => void, mainTag?: boolean }) { export default function TagInput({
const [keyword, setKeyword] = useState<string>("") onAdd,
const [tags, setTags] = useState<Tag[]>([]) mainTag,
const [error, setError] = useState<string | null>(null) }: {
const [isLoading, setLoading] = useState(false) 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 { t } = useTranslation();
const searchTags = async (keyword: string) => { const searchTags = async (keyword: string) => {
if (keyword.length === 0) { if (keyword.length === 0) {
return return;
} }
setLoading(true) setLoading(true);
setTags([]) setTags([]);
setError(null) setError(null);
const res = await network.searchTags(keyword, mainTag) const res = await network.searchTags(keyword, mainTag);
if (!res.success) { if (!res.success) {
setError(res.message) setError(res.message);
setLoading(false) setLoading(false);
return return;
}
setTags(res.data!)
setLoading(false)
} }
setTags(res.data!);
setLoading(false);
};
const handleChange = async (v: string) => { const handleChange = async (v: string) => {
setKeyword(v) setKeyword(v);
setTags([]) setTags([]);
setError(null) setError(null);
if (v.length !== 0) { if (v.length !== 0) {
setLoading(true) setLoading(true);
debounce.current.run(() => searchTags(v)) debounce.current.run(() => searchTags(v));
} else { } else {
setLoading(false) setLoading(false);
debounce.current.cancel() debounce.current.cancel();
}
} }
};
const handleCreateTag = async (name: string) => { const handleCreateTag = async (name: string) => {
setLoading(true) setLoading(true);
const res = await network.createTag(name) const res = await network.createTag(name);
if (!res.success) { if (!res.success) {
setError(res.message) setError(res.message);
setLoading(false) setLoading(false);
return return;
}
onAdd(res.data!)
setKeyword("")
setTags([])
setLoading(false)
const input = document.getElementById("search_tags_input") as HTMLInputElement
input.blur()
} }
onAdd(res.data!);
setKeyword("");
setTags([]);
setLoading(false);
const input = document.getElementById(
"search_tags_input",
) as HTMLInputElement;
input.blur();
};
let dropdownContent let dropdownContent;
if (error) { if (error) {
dropdownContent = <div className="alert alert-error my-2"> dropdownContent = (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none" <div className="alert alert-error my-2">
viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" xmlns="http://www.w3.org/2000/svg"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> 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> </svg>
<span>{error}</span> <span>{error}</span>
</div> </div>
);
} else if (!keyword) { } 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} /> <LuInfo size={20} />
<span className={"w-2"} /> <span className={"w-2"} />
<span className={"flex-1"}>{t("Please enter a search keyword")}</span> <span className={"flex-1"}>{t("Please enter a search keyword")}</span>
</div> </div>
);
} else if (isLoading) { } 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={"loading loading-spinner loading-sm"}></span>
<span className={"w-2"} /> <span className={"w-2"} />
<span className={"flex-1"}>{t("Searching...")}</span> <span className={"flex-1"}>{t("Searching...")}</span>
</div> </div>
);
} else { } else {
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined;
dropdownContent = <> dropdownContent = (
{ <>
tags.map((t) => { {tags.map((t) => {
return <li key={t.id} onClick={() => { return (
<li
key={t.id}
onClick={() => {
onAdd(t); onAdd(t);
setKeyword("") setKeyword("");
setTags([]) setTags([]);
const input = document.getElementById("search_tags_input") as HTMLInputElement const input = document.getElementById(
input.blur() "search_tags_input",
}}><a> ) as HTMLInputElement;
input.blur();
}}
>
<a>
<span>{t.name}</span> <span>{t.name}</span>
{t.type && <span className="badge badge-secondary badge-sm ml-2 text-xs">{t.type}</span>} {t.type && (
</a></li> <span className="badge badge-secondary badge-sm ml-2 text-xs">
}) {t.type}
} </span>
{ )}
!haveExactMatch && <li onClick={() => { </a>
handleCreateTag(keyword) </li>
}}><a>{t("Create Tag")}: {keyword}</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"> <label className="input w-64">
<MdSearch size={18}/> <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> </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} {dropdownContent}
</ul> </ul>
</div> </div>
);
} }
class Debounce { class Debounce {
private timer: number | null = null private timer: number | null = null;
private readonly delay: number private readonly delay: number;
constructor(delay: number) { constructor(delay: number) {
this.delay = delay this.delay = delay;
} }
run(callback: () => void) { run(callback: () => void) {
if (this.timer) { if (this.timer) {
clearTimeout(this.timer) clearTimeout(this.timer);
} }
this.timer = setTimeout(() => { this.timer = setTimeout(() => {
callback() callback();
}, this.delay) }, this.delay);
} }
cancel() { cancel() {
if (this.timer) { if (this.timer) {
clearTimeout(this.timer) clearTimeout(this.timer);
this.timer = null this.timer = null;
} }
} }
} }
export function QuickAddTagDialog({ onAdded }: { onAdded: (tags: Tag[]) => void }) { export function QuickAddTagDialog({
const {t} = useTranslation(); 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 () => { const handleSubmit = async () => {
if (isLoading) { if (isLoading) {
return return;
} }
if (text.trim().length === 0) { if (text.trim().length === 0) {
return return;
} }
setError(null) setError(null);
const names = text.split(separator).filter((n) => n.length > 0) const names = text.split(separator).filter((n) => n.length > 0);
setLoading(true) setLoading(true);
const res = await network.getOrCreateTags(names, type) const res = await network.getOrCreateTags(names, type);
setLoading(false) setLoading(false);
if (!res.success) { if (!res.success) {
setError(res.message) setError(res.message);
return return;
}
const tags = res.data!
onAdded(tags)
setText("")
setType("")
const dialog = document.getElementById("quick_add_tag_dialog") as HTMLDialogElement
dialog.close()
} }
const tags = res.data!;
onAdded(tags);
setText("");
setType("");
const dialog = document.getElementById(
"quick_add_tag_dialog",
) as HTMLDialogElement;
dialog.close();
};
return <> return (
<Button className={"btn-soft btn-primary"} onClick={() => { <>
const dialog = document.getElementById("quick_add_tag_dialog") as HTMLDialogElement <Button
dialog.showModal() className={"btn-soft btn-primary"}
}}>{t("Quick Add")}</Button> 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"> <dialog id="quick_add_tag_dialog" className="modal">
<div className="modal-box"> <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"> <p className="py-2 text-sm">
{t("Input tags separated by separator.")} {t("Input tags separated by separator.")}
<br/> <br />
{t("If the tag does not exist, it will be created automatically.")} {t("If the tag does not exist, it will be created automatically.")}
<br/> <br />
{t("Optionally, you can specify a type for the new tags.")} {t("Optionally, you can specify a type for the new tags.")}
</p> </p>
<p className={"flex my-2"}> <p className={"flex my-2"}>
<span className={"flex-1"}>{t("Separator")}:</span> <span className={"flex-1"}>{t("Separator")}:</span>
<label className="label text-sm mx-2"> <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 Comma
</label> </label>
<label className="label text-sm mx-2"> <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 Semicolon
</label> </label>
<label className="label text-sm mx-2"> <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 Space
</label> </label>
</p> </p>
<TextArea value={text} onChange={(e) => setText(e.target.value)} label={"Tags"}/> <TextArea
<Input value={type} onChange={(e) => setType(e.target.value)} label={"Type"}/> value={text}
{error && <ErrorAlert className={"mt-2"} message={error}/>} 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"> <div className="modal-action">
<form method="dialog"> <form method="dialog">
<Button className="btn">{t("Cancel")}</Button> <Button className="btn">{t("Cancel")}</Button>
</form> </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>
</div> </div>
</dialog> </dialog>
</> </>
);
} }

View File

@@ -1,14 +1,20 @@
export default function showToast({message, type}: {message: string, type?: "success" | "error" | "warning" | "info"}) { export default function showToast({
type = type || "info" message,
const div = document.createElement("div") type,
}: {
message: string;
type?: "success" | "error" | "warning" | "info";
}) {
type = type || "info";
const div = document.createElement("div");
div.innerHTML = ` div.innerHTML = `
<div class="toast toast-center"> <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> <span>${message}</span>
</div> </div>
</div>` </div>`;
document.body.appendChild(div) document.body.appendChild(div);
setTimeout(() => { setTimeout(() => {
div.remove() div.remove();
}, 3000) }, 3000);
} }

View File

@@ -7,32 +7,47 @@ export default function UploadingSideBar() {
useEffect(() => { useEffect(() => {
const listener = () => { const listener = () => {
console.log("Uploading tasks changed; show uploading: ", uploadingManager.hasTasks()); console.log(
setShowUploading(uploadingManager.hasTasks()) "Uploading tasks changed; show uploading: ",
uploadingManager.hasTasks(),
);
setShowUploading(uploadingManager.hasTasks());
}; };
uploadingManager.addListener(listener) uploadingManager.addListener(listener);
return () => { return () => {
uploadingManager.removeListener(listener) uploadingManager.removeListener(listener);
} };
}, []); }, []);
if (!showUploading) { if (!showUploading) {
return <></> return <></>;
} }
return <> return (
<label htmlFor={"uploading-drawer"} className={"btn btn-square btn-ghost relative btn-accent text-primary"}> <>
<label
htmlFor={"uploading-drawer"}
className={"btn btn-square btn-ghost relative btn-accent text-primary"}
>
<div className={"w-6 h-6 overflow-hidden relative"}> <div className={"w-6 h-6 overflow-hidden relative"}>
<MdArrowUpward className={"move-up-animation pb-0.5"} size={24} /> <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 className={"absolute border-b-2 w-5 bottom-1 left-0.5"}></div>
</div> </div>
</label> </label>
<div className="drawer w-0"> <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"> <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="menu bg-base-200 text-base-content h-full w-80 p-4 overflow-y-auto ">
<div className={"grid grid-cols-1"}> <div className={"grid grid-cols-1"}>
<h2 className={"text-xl mb-2"}>Uploading</h2> <h2 className={"text-xl mb-2"}>Uploading</h2>
@@ -42,6 +57,7 @@ export default function UploadingSideBar() {
</div> </div>
</div> </div>
</> </>
);
} }
function UploadingList() { function UploadingList() {
@@ -50,22 +66,22 @@ function UploadingList() {
useEffect(() => { useEffect(() => {
const listener = () => { const listener = () => {
setTasks(uploadingManager.getTasks()); setTasks(uploadingManager.getTasks());
} };
uploadingManager.addListener(listener) uploadingManager.addListener(listener);
return () => { return () => {
uploadingManager.removeListener(listener) uploadingManager.removeListener(listener);
} };
}, []); }, []);
return <> return (
{ <>
tasks.map((task) => { {tasks.map((task) => {
return <TaskTile key={task.id} task={task} /> return <TaskTile key={task.id} task={task} />;
}) })}
}
</> </>
);
} }
function TaskTile({ task }: { task: UploadingTask }) { function TaskTile({ task }: { task: UploadingTask }) {
@@ -77,31 +93,46 @@ function TaskTile({ task }: { task: UploadingTask }) {
const listener = () => { const listener = () => {
setProgress(task.progress); setProgress(task.progress);
setError(task.errorMessage); setError(task.errorMessage);
} };
task.addListener(listener) task.addListener(listener);
return () => { return () => {
task.removeListener(listener) task.removeListener(listener);
} };
}, [task]); }, [task]);
return <div className={"card card-border border-base-300 p-2 my-2 w-full"}> return (
<p className={"p-1 mb-2 w-full break-all line-clamp-2"}>{task.filename}</p> <div className={"card card-border border-base-300 p-2 my-2 w-full"}>
<progress className="progress progress-primary my-2" value={100 * progress} max={100} /> <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>} {error && <p className={"text-error p-1"}>{error}</p>}
<div className={"my-2 flex flex-row-reverse"}> <div className={"my-2 flex flex-row-reverse"}>
{ {error && (
error && <button className={"btn h-7 mr-1"} onClick={() => { <button
className={"btn h-7 mr-1"}
onClick={() => {
task.start(); task.start();
}}> }}
>
Retry Retry
</button> </button>
} )}
<button className={"btn btn-error h-7"} onClick={() => { <button
const dialog = document.getElementById(`cancel_task_${task.id}`) as HTMLDialogElement; className={"btn btn-error h-7"}
onClick={() => {
const dialog = document.getElementById(
`cancel_task_${task.id}`,
) as HTMLDialogElement;
dialog.showModal(); dialog.showModal();
}}> }}
>
Cancel Cancel
</button> </button>
</div> </div>
@@ -113,15 +144,22 @@ function TaskTile({ task }: { task: UploadingTask }) {
<form method="dialog"> <form method="dialog">
<button className="btn">Close</button> <button className="btn">Close</button>
</form> </form>
<button className="btn btn-error mx-2" type={"button"} onClick={() => { <button
className="btn btn-error mx-2"
type={"button"}
onClick={() => {
task.cancel(); task.cancel();
const dialog = document.getElementById(`cancel_task_${task.id}`) as HTMLDialogElement; const dialog = document.getElementById(
`cancel_task_${task.id}`,
) as HTMLDialogElement;
dialog.close(); dialog.close();
}}> }}
>
Confirm Confirm
</button> </button>
</div> </div>
</div> </div>
</dialog> </dialog>
</div> </div>
);
} }

View File

@@ -1,85 +1,94 @@
export const i18nData = { export const i18nData = {
"en": { en: {
translation: { translation: {
"My Profile": "My Profile", "My Profile": "My Profile",
"Publish": "Publish", Publish: "Publish",
"Log out": "Log out", "Log out": "Log out",
"Are you sure you want to log out?": "Are you sure you want to log out?", "Are you sure you want to log out?": "Are you sure you want to log out?",
"Cancel": "Cancel", Cancel: "Cancel",
"Confirm": "Confirm", Confirm: "Confirm",
"Search": "Search", Search: "Search",
"Login": "Login", Login: "Login",
"Register": "Register", Register: "Register",
"Username": "Username", Username: "Username",
"Password": "Password", Password: "Password",
"Confirm Password": "Confirm 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", "Passwords do not match": "Passwords do not match",
"Continue": "Continue", Continue: "Continue",
"Don't have an account? Register": "Don't have an account? Register", "Don't have an account? Register": "Don't have an account? Register",
"Already have an account? Login": "Already have an account? Login", "Already have an account? Login": "Already have an account? Login",
"Publish Resource": "Publish Resource", "Publish Resource": "Publish Resource",
"All information can be modified after publishing": "All information can be modified after publishing", "All information can be modified after publishing":
"Title": "Title", "All information can be modified after publishing",
Title: "Title",
"Alternative Titles": "Alternative Titles", "Alternative Titles": "Alternative Titles",
"Add Alternative Title": "Add Alternative Title", "Add Alternative Title": "Add Alternative Title",
"Tags": "Tags", Tags: "Tags",
"Description": "Description", Description: "Description",
"Use Markdown format": "Use Markdown format", "Use Markdown format": "Use Markdown format",
"Images": "Images", 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", "Images will not be displayed automatically, you need to reference them in the description":
"Preview": "Preview", "Images will not be displayed automatically, you need to reference them in the description",
"Link": "Link", Preview: "Preview",
"Action": "Action", Link: "Link",
Action: "Action",
"Upload Image": "Upload Image", "Upload Image": "Upload Image",
"Error": "Error", Error: "Error",
"Title cannot be empty": "Title cannot be empty", "Title cannot be empty": "Title cannot be empty",
"Alternative title cannot be empty": "Alternative title cannot be empty", "Alternative title cannot be empty": "Alternative title cannot be empty",
"At least one tag required": "At least one tag required", "At least one tag required": "At least one tag required",
"Description cannot be empty": "Description cannot be empty", "Description cannot be empty": "Description cannot be empty",
"Loading": "Loading", Loading: "Loading",
"Enter a search keyword to continue": "Enter a search keyword to continue", "Enter a search keyword to continue":
"Enter a search keyword to continue",
"My Info": "My Info", "My Info": "My Info",
"Server": "Server", Server: "Server",
// Management page translations // Management page translations
"Manage": "Manage", Manage: "Manage",
"Storage": "Storage", Storage: "Storage",
"Users": "Users", 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 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.", "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 // Storage management
"No storage found. Please create a new storage.": "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.",
Name: "Name",
"Created At": "Created At", "Created At": "Created At",
"Actions": "Actions", Actions: "Actions",
"Delete Storage": "Delete Storage", "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.", "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.",
Delete: "Delete",
"Storage deleted successfully": "Storage deleted successfully", "Storage deleted successfully": "Storage deleted successfully",
"New Storage": "New Storage", "New Storage": "New Storage",
"Type": "Type", Type: "Type",
"Local": "Local", Local: "Local",
"S3": "S3", S3: "S3",
"Path": "Path", Path: "Path",
"Max Size (MB)": "Max Size (MB)", "Max Size (MB)": "Max Size (MB)",
"Endpoint": "Endpoint", Endpoint: "Endpoint",
"Access Key ID": "Access Key ID", "Access Key ID": "Access Key ID",
"Secret Access Key": "Secret Access Key", "Secret Access Key": "Secret Access Key",
"Bucket Name": "Bucket Name", "Bucket Name": "Bucket Name",
"All fields are required": "All fields are required", "All fields are required": "All fields are required",
"Storage created successfully": "Storage created successfully", "Storage created successfully": "Storage created successfully",
"Close": "Close", Close: "Close",
"Submit": "Submit", Submit: "Submit",
// User management // User management
"Admin": "Admin", Admin: "Admin",
"Can Upload": "Can Upload", "Can Upload": "Can Upload",
"Yes": "Yes", Yes: "Yes",
"No": "No", No: "No",
"Delete User": "Delete User", "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.", "This action cannot be undone.": "This action cannot be undone.",
"User deleted successfully": "User deleted successfully", "User deleted successfully": "User deleted successfully",
"Set as user": "Set as user", "Set as user": "Set as user",
@@ -88,31 +97,38 @@ export const i18nData = {
"Grant upload permission": "Grant upload permission", "Grant upload permission": "Grant upload permission",
"User set as admin successfully": "User set as admin successfully", "User set as admin successfully": "User set as admin successfully",
"User set as user successfully": "User set as user successfully", "User set as user successfully": "User set as user successfully",
"User set as 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", "User set as upload permission successfully",
"User removed upload permission successfully":
"User removed upload permission successfully",
// Resource details page // Resource details page
"Resource ID is required": "Resource ID is required", "Resource ID is required": "Resource ID is required",
"Files": "Files", Files: "Files",
"Comments": "Comments", Comments: "Comments",
"Upload": "Upload", Upload: "Upload",
"Create File": "Create File", "Create File": "Create File",
"Please select a file type": "Please select a file type", "Please select a file type": "Please select a file type",
"Please fill in all fields": "Please fill in all fields", "Please fill in all fields": "Please fill in all fields",
"File created successfully": "File created successfully", "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", "Please select a file and storage": "Please select a file and storage",
"Redirect": "Redirect", Redirect: "Redirect",
"User who click the file will be redirected to the URL": "User who click the file will be redirected to the URL", "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", "File Name": "File Name",
"URL": "URL", 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.", "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", "Select Storage": "Select Storage",
"Resource Details": "Resource Details", "Resource Details": "Resource Details",
"Delete Resource": "Delete Resource", "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", "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 // New translations
"Change Avatar": "Change Avatar", "Change Avatar": "Change Avatar",
@@ -120,7 +136,7 @@ export const i18nData = {
"Change Password": "Change Password", "Change Password": "Change Password",
"New Username": "New Username", "New Username": "New Username",
"Enter new username": "Enter new username", "Enter new username": "Enter new username",
"Save": "Save", Save: "Save",
"Current Password": "Current Password", "Current Password": "Current Password",
"Enter current password": "Enter current password", "Enter current password": "Enter current password",
"New Password": "New Password", "New Password": "New Password",
@@ -135,14 +151,17 @@ export const i18nData = {
"Update server config successfully": "Update server config successfully", "Update server config successfully": "Update server config successfully",
"Max uploading size (MB)": "Max uploading size (MB)", "Max uploading size (MB)": "Max uploading size (MB)",
"Max file size (MB)": "Max file 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", "Allow register": "Allow register",
"Server name": "Server name", "Server name": "Server name",
"Server description": "Server description", "Server description": "Server description",
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile Site Key", "Cloudflare Turnstile Site Key": "Cloudflare Turnstile Site Key",
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile Secret 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.", "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.",
"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", "Please enter a search keyword": "Please enter a search keyword",
"Searching...": "Searching...", "Searching...": "Searching...",
"Create Tag": "Create Tag", "Create Tag": "Create Tag",
@@ -153,7 +172,7 @@ export const i18nData = {
"Tag not found": "Tag not found", "Tag not found": "Tag not found",
"Description is too long": "Description is too long", "Description is too long": "Description is too long",
"Unknown error": "Unknown error", "Unknown error": "Unknown error",
"Edit": "Edit", Edit: "Edit",
"Edit Tag": "Edit Tag", "Edit Tag": "Edit Tag",
"Set the description of the tag.": "Set the description of the tag.", "Set the description of the tag.": "Set the description of the tag.",
"Use markdown format.": "Use markdown format.", "Use markdown format.": "Use markdown format.",
@@ -166,99 +185,109 @@ export const i18nData = {
"Downloads Ascending": "Downloads Ascending", "Downloads Ascending": "Downloads Ascending",
"Downloads Descending": "Downloads Descending", "Downloads Descending": "Downloads Descending",
"File Url": "File Url", "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", "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.", "Please check your network if the verification takes too long or the captcha does not appear.":
"About": "About", "Please check your network if the verification takes too long or the captcha does not appear.",
"Home": "Home", About: "About",
"Other": "Other", Home: "Home",
Other: "Other",
"Quick Add": "Quick Add", "Quick Add": "Quick Add",
"Add Tags": "Add Tags", "Add Tags": "Add Tags",
"Input tags separated by separator.": "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.", "Input tags separated by separator.",
"Optionally, you can specify a type for the new tags.": "Optionally, you can specify a type for the new tags.", "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", "Upload Clipboard Image": "Upload Clipboard Image",
} },
}, },
"zh-CN": { "zh-CN": {
translation: { translation: {
"My Profile": "我的资料", "My Profile": "我的资料",
"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: "密码",
"Confirm Password": "确认密码", "Confirm Password": "确认密码",
"Username and password cannot be empty": "用户名和密码不能为空", "Username and password cannot be empty": "用户名和密码不能为空",
"Passwords do not match": "两次输入的密码不匹配", "Passwords do not match": "两次输入的密码不匹配",
"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: "标题",
"Alternative Titles": "其他标题", "Alternative Titles": "其他标题",
"Add Alternative Title": "新增标题", "Add Alternative Title": "新增标题",
"Tags": "标签", Tags: "标签",
"Description": "介绍", Description: "介绍",
"Use Markdown format": "使用Markdown格式", "Use Markdown format": "使用Markdown格式",
"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": "预览", "图片不会被自动显示, 你需要在介绍中引用它们",
"Link": "链接", Preview: "预览",
"Action": "操作", Link: "链接",
Action: "操作",
"Upload Image": "上传图片", "Upload Image": "上传图片",
"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": "输入搜索关键词以继续",
"My Info": "个人信息", "My Info": "个人信息",
"Server": "服务器", Server: "服务器",
// Management page translations // 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.": "您无权访问此页面。",
// Storage management // Storage management
"No storage found. Please create a new storage.": "未找到存储。请创建新的存储。", "No storage found. Please create a new storage.":
"Name": "名称", "未找到存储。请创建新的存储。",
Name: "名称",
"Created At": "创建于", "Created At": "创建于",
"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: "删除",
"Storage deleted successfully": "存储已成功删除", "Storage deleted successfully": "存储已成功删除",
"New Storage": "新建存储", "New Storage": "新建存储",
"Type": "类型", Type: "类型",
"Local": "本地", Local: "本地",
"S3": "S3", S3: "S3",
"Path": "路径", Path: "路径",
"Max Size (MB)": "最大大小 (MB)", "Max Size (MB)": "最大大小 (MB)",
"Endpoint": "终端节点", Endpoint: "终端节点",
"Access Key ID": "访问密钥 ID", "Access Key ID": "访问密钥 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: "提交",
// User management // User management
"Admin": "管理员", Admin: "管理员",
"Can Upload": "可上传", "Can Upload": "可上传",
"Yes": "是", Yes: "是",
"No": "否", No: "否",
"Delete User": "删除用户", "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.": "此操作不可撤销。",
@@ -274,20 +303,22 @@ export const i18nData = {
// Resource details page // Resource details page
"Resource ID is required": "资源ID是必需的", "Resource ID is required": "资源ID是必需的",
"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.": "成功创建上传任务。",
"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": "点击文件的用户将被重定向到URL", "User who click the file will be redirected to the URL":
"点击文件的用户将被重定向到URL",
"File Name": "文件名", "File Name": "文件名",
"URL": "URL", 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": "删除资源",
@@ -301,7 +332,7 @@ export const i18nData = {
"Change Password": "更改密码", "Change Password": "更改密码",
"New Username": "新用户名", "New Username": "新用户名",
"Enter new username": "输入新用户名", "Enter new username": "输入新用户名",
"Save": "保存", Save: "保存",
"Current Password": "当前密码", "Current Password": "当前密码",
"Enter current password": "输入当前密码", "Enter current password": "输入当前密码",
"New Password": "新密码", "New Password": "新密码",
@@ -322,8 +353,10 @@ export const i18nData = {
"Server description": "服务器描述", "Server description": "服务器描述",
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile 站点密钥", "Cloudflare Turnstile Site Key": "Cloudflare Turnstile 站点密钥",
"Cloudflare Turnstile Secret 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 密钥,将在注册和下载时启用验证", "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": "第一张图片将用作封面图片", "如果设置了 Cloudflare Turnstile 密钥,将在注册和下载时启用验证",
"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": "创建标签",
@@ -334,7 +367,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 Tag": "编辑标签", "Edit Tag": "编辑标签",
"Set the description of the tag.": "设置标签的描述。", "Set the description of the tag.": "设置标签的描述。",
"Use markdown format.": "使用Markdown格式。", "Use markdown format.": "使用Markdown格式。",
@@ -347,99 +380,108 @@ 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.":
"提供一个文件链接供服务器下载,文件将被移动到选定的存储中。",
"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": "关于", "如果验证时间过长或验证码未出现, 请检查您的网络连接",
"Home": "首页", About: "关于",
"Other": "其他", Home: "首页",
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.":
"您可以选择为新标签指定一个类型。",
"Upload Clipboard Image": "上传剪贴板图片", "Upload Clipboard Image": "上传剪贴板图片",
} },
}, },
"zh-TW": { "zh-TW": {
translation: { translation: {
"My Profile": "我的資料", "My Profile": "我的資料",
"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: "密碼",
"Confirm Password": "確認密碼", "Confirm Password": "確認密碼",
"Username and password cannot be empty": "用戶名和密碼不能為空", "Username and password cannot be empty": "用戶名和密碼不能為空",
"Passwords do not match": "兩次輸入的密碼不匹配", "Passwords do not match": "兩次輸入的密碼不匹配",
"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: "標題",
"Alternative Titles": "其他標題", "Alternative Titles": "其他標題",
"Add Alternative Title": "新增標題", "Add Alternative Title": "新增標題",
"Tags": "標籤", Tags: "標籤",
"Description": "介紹", Description: "介紹",
"Use Markdown format": "使用Markdown格式", "Use Markdown format": "使用Markdown格式",
"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": "預覽", "圖片不會自動顯示,需在介紹中引用",
"Link": "連結", Preview: "預覽",
"Action": "操作", Link: "連結",
Action: "操作",
"Upload Image": "上傳圖片", "Upload Image": "上傳圖片",
"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": "輸入搜尋關鍵字以繼續",
"My Info": "個人信息", "My Info": "個人信息",
"Server": "伺服器", Server: "伺服器",
// Management page translations // 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.": "您無權訪問此頁面。",
// Storage management // Storage management
"No storage found. Please create a new storage.": "未找到儲存。請創建新的儲存。", "No storage found. Please create a new storage.":
"Name": "名稱", "未找到儲存。請創建新的儲存。",
Name: "名稱",
"Created At": "建立於", "Created At": "建立於",
"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: "刪除",
"Storage deleted successfully": "儲存已成功刪除", "Storage deleted successfully": "儲存已成功刪除",
"New Storage": "新建儲存", "New Storage": "新建儲存",
"Type": "類型", Type: "類型",
"Local": "本地", Local: "本地",
"S3": "S3", S3: "S3",
"Path": "路徑", Path: "路徑",
"Max Size (MB)": "最大大小 (MB)", "Max Size (MB)": "最大大小 (MB)",
"Endpoint": "端點", Endpoint: "端點",
"Access Key ID": "訪問密鑰 ID", "Access Key ID": "訪問密鑰 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: "提交",
// User management // User management
"Admin": "管理員", Admin: "管理員",
"Can Upload": "可上傳", "Can Upload": "可上傳",
"Yes": "是", Yes: "是",
"No": "否", No: "否",
"Delete User": "刪除用戶", "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.": "此操作不可撤銷。",
@@ -455,20 +497,22 @@ export const i18nData = {
// Resource details page // Resource details page
"Resource ID is required": "資源ID是必需的", "Resource ID is required": "資源ID是必需的",
"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.": "成功創建上傳任務。",
"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": "點擊檔案的用戶將被重定向到URL", "User who click the file will be redirected to the URL":
"點擊檔案的用戶將被重定向到URL",
"File Name": "檔案名", "File Name": "檔案名",
"URL": "URL", 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": "刪除資源",
@@ -482,7 +526,7 @@ export const i18nData = {
"Change Password": "更改密碼", "Change Password": "更改密碼",
"New Username": "新用戶名", "New Username": "新用戶名",
"Enter new username": "輸入新用戶名", "Enter new username": "輸入新用戶名",
"Save": "儲存", Save: "儲存",
"Current Password": "當前密碼", "Current Password": "當前密碼",
"Enter current password": "輸入當前密碼", "Enter current password": "輸入當前密碼",
"New Password": "新密碼", "New Password": "新密碼",
@@ -503,8 +547,10 @@ export const i18nData = {
"Server description": "伺服器描述", "Server description": "伺服器描述",
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile 網站密鑰", "Cloudflare Turnstile Site Key": "Cloudflare Turnstile 網站密鑰",
"Cloudflare Turnstile Secret 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 密鑰,將在註冊和下載時啟用驗證", "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": "第一張圖片將用作封面圖片", "如果設置了 Cloudflare Turnstile 密鑰,將在註冊和下載時啟用驗證",
"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": "創建標籤",
@@ -515,7 +561,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 Tag": "編輯標籤", "Edit Tag": "編輯標籤",
"Set the description of the tag.": "設置標籤的描述。", "Set the description of the tag.": "設置標籤的描述。",
"Use markdown format.": "使用Markdown格式。", "Use markdown format.": "使用Markdown格式。",
@@ -528,18 +574,22 @@ 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.":
"提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。",
"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": "關於", "如果驗證時間過長或驗證碼未出現,請檢查您的網絡連接。",
"Home": "首頁", About: "關於",
"Other": "其他", Home: "首頁",
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.":
"您可以選擇為新標籤指定一個類型。",
"Upload Clipboard Image": "上傳剪貼板圖片", "Upload Clipboard Image": "上傳剪貼板圖片",
} },
} },
} };

View File

@@ -1,11 +1,11 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import { createRoot } from 'react-dom/client' import { createRoot } from "react-dom/client";
import './index.css' import "./index.css";
import App from "./app.tsx"; import App from "./app.tsx";
import i18n from "i18next"; import i18n from "i18next";
import {initReactI18next} from "react-i18next"; import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import {i18nData} from "./i18n.ts"; import { i18nData } from "./i18n.ts";
import AppContext from "./components/AppContext.tsx"; import AppContext from "./components/AppContext.tsx";
i18n i18n
@@ -16,14 +16,15 @@ i18n
debug: true, debug: true,
fallbackLng: "en", fallbackLng: "en",
interpolation: { interpolation: {
escapeValue: false escapeValue: false,
} },
}).then(() => { })
createRoot(document.getElementById('root')!).render( .then(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<AppContext> <AppContext>
<App/> <App />
</AppContext> </AppContext>
</StrictMode>, </StrictMode>,
) );
}) });

View File

@@ -94,9 +94,10 @@ article {
background-color: var(--color-base-200); background-color: var(--color-base-200);
padding: 2px 4px; padding: 2px 4px;
border-radius: 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{ iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -17,11 +17,11 @@ class Listenable {
} }
removeListener(listener: () => void) { removeListener(listener: () => void) {
this.listeners = this.listeners.filter(l => l !== listener); this.listeners = this.listeners.filter((l) => l !== listener);
} }
notifyListeners() { notifyListeners() {
this.listeners.forEach(listener => listener()); this.listeners.forEach((listener) => listener());
} }
} }
@@ -36,7 +36,7 @@ export class UploadingTask extends Listenable {
uploadingBlocks: number[] = []; uploadingBlocks: number[] = [];
finishedBlocksCount: number = 0; finishedBlocksCount: number = 0;
onFinished: (() => void); onFinished: () => void;
get filename() { get filename() {
return this.file.name; return this.file.name;
@@ -49,7 +49,13 @@ export class UploadingTask extends Listenable {
return this.finishedBlocksCount / this.blocks.length; 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(); super();
this.id = id; this.id = id;
this.file = file; this.file = file;
@@ -117,7 +123,7 @@ export class UploadingTask extends Listenable {
} }
this.blocks[index] = true; this.blocks[index] = true;
this.finishedBlocksCount++; this.finishedBlocksCount++;
this.uploadingBlocks = this.uploadingBlocks.filter(i => i !== index); this.uploadingBlocks = this.uploadingBlocks.filter((i) => i !== index);
index++; index++;
this.notifyListeners(); this.notifyListeners();
} }
@@ -133,15 +139,14 @@ export class UploadingTask extends Listenable {
this.upload(), this.upload(),
this.upload(), this.upload(),
this.upload(), this.upload(),
]) ]);
if (this.status !== UploadingStatus.UPLOADING) { if (this.status !== UploadingStatus.UPLOADING) {
return; return;
} }
let md5 = ""; let md5 = "";
try { try {
md5 = await this.calculateMd5(this.file); md5 = await this.calculateMd5(this.file);
} } catch (e) {
catch (e) {
this.status = UploadingStatus.ERROR; this.status = UploadingStatus.ERROR;
this.errorMessage = "Failed to calculate md5"; this.errorMessage = "Failed to calculate md5";
this.notifyListeners(); this.notifyListeners();
@@ -180,40 +185,55 @@ class UploadingManager extends Listenable {
this.tasks[0].removeListener(this.onTaskStatusChanged); this.tasks[0].removeListener(this.onTaskStatusChanged);
this.tasks.shift(); this.tasks.shift();
this.onTaskStatusChanged(); 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[0].removeListener(this.onTaskStatusChanged);
this.tasks.shift(); this.tasks.shift();
this.onTaskStatusChanged(); this.onTaskStatusChanged();
} }
this.notifyListeners(); 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( const res = await network.initFileUpload(
file.name, file.name,
description, description,
file.size, file.size,
resourceID, resourceID,
storageID, storageID,
) );
if (!res.success) { if (!res.success) {
return { return {
success: false, success: false,
message: res.message, 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); task.addListener(this.onTaskStatusChanged);
this.tasks.push(task); this.tasks.push(task);
this.onTaskStatusChanged(); this.onTaskStatusChanged();
return { return {
success: true, success: true,
message: "ok", message: "ok",
} };
} }
getTasks() { getTasks() {
return Array.from(this.tasks) return Array.from(this.tasks);
} }
hasTasks() { hasTasks() {
@@ -228,4 +248,4 @@ window.addEventListener("beforeunload", () => {
return "Uploading files, are you sure you want to leave?"; return "Uploading files, are you sure you want to leave?";
} }
return undefined; return undefined;
}) });

View File

@@ -1,38 +1,55 @@
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import {app} from "../app.ts"; import { app } from "../app.ts";
import {ReactElement, ReactNode} from "react"; import { ReactElement, ReactNode } from "react";
export default function AboutPage() { export default function AboutPage() {
return <article className={"p-4"}> return (
<Markdown components={{ <article className={"p-4"}>
"a": ({node, ...props}) => { <Markdown
const href = props.href as string components={{
a: ({ node, ...props }) => {
const href = props.href as string;
// @ts-ignore // @ts-ignore
if (props.children?.length === 2) { if (props.children?.length === 2) {
// @ts-ignore // @ts-ignore
const first = props.children[0] as ReactNode const first = props.children[0] as ReactNode;
// @ts-ignore // @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")) { if (
const img = first as ReactElement typeof first === "object" &&
(typeof second === "string" || typeof second === "object")
) {
const img = first as ReactElement;
// @ts-ignore // @ts-ignore
if (img.type === "img") { 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}> return (
<figure className={"max-h-72 max-w-96"}> <a
{img} className={
</figure> "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"}> <div className={"card-body text-base-content text-lg"}>
{second} {second}
</div> </div>
</a> </a>
);
} }
} }
} }
return <a href={href} target={"_blank"}>{props.children}</a> return (
} <a href={href} target={"_blank"}>
}}> {props.children}
</a>
);
},
}}
>
{app.siteInfo} {app.siteInfo}
</Markdown> </Markdown>
</article> </article>
);
} }

View File

@@ -1,109 +1,121 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import {MdAdd, MdClose, MdDelete, MdOutlineInfo} from "react-icons/md"; import { MdAdd, MdClose, MdDelete, MdOutlineInfo } from "react-icons/md";
import { Tag } from "../network/models.ts"; import { Tag } from "../network/models.ts";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import {useNavigate, useParams} from "react-router"; import { useNavigate, useParams } from "react-router";
import showToast from "../components/toast.ts"; import showToast from "../components/toast.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { app } from "../app.ts"; import { app } from "../app.ts";
import { ErrorAlert } from "../components/alert.tsx"; import { ErrorAlert } from "../components/alert.tsx";
import Loading from "../components/loading.tsx"; import Loading from "../components/loading.tsx";
import TagInput, {QuickAddTagDialog} from "../components/tag_input.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() { export default function EditResourcePage() {
const [title, setTitle] = useState<string>("") const [title, setTitle] = useState<string>("");
const [altTitles, setAltTitles] = useState<string[]>([]) const [altTitles, setAltTitles] = useState<string[]>([]);
const [tags, setTags] = useState<Tag[]>([]) const [tags, setTags] = useState<Tag[]>([]);
const [article, setArticle] = useState<string>("") const [article, setArticle] = useState<string>("");
const [images, setImages] = useState<number[]>([]) const [images, setImages] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [isSubmitting, setSubmitting] = useState(false) const [isSubmitting, setSubmitting] = useState(false);
const [isLoading, setLoading] = useState(true) const [isLoading, setLoading] = useState(true);
const navigate = useNavigate() const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
document.title = t("Edit Resource"); document.title = t("Edit Resource");
}, [t]) }, [t]);
const {rid} = useParams() const { rid } = useParams();
const id = parseInt(rid || "") const id = parseInt(rid || "");
useEffect(() => { useEffect(() => {
if (isNaN(id)) { if (isNaN(id)) {
return return;
} }
network.getResourceDetails(id).then((res) => { network.getResourceDetails(id).then((res) => {
if (res.success) { if (res.success) {
const data = res.data! const data = res.data!;
setTitle(data.title) setTitle(data.title);
setAltTitles(data.alternativeTitles) setAltTitles(data.alternativeTitles);
setTags(data.tags) setTags(data.tags);
setArticle(data.article) setArticle(data.article);
setImages(data.images.map(i => i.id)) setImages(data.images.map((i) => i.id));
setLoading(false) setLoading(false);
} else { } else {
showToast({ message: t("Failed to load resource"), type: "error" }) showToast({ message: t("Failed to load resource"), type: "error" });
} }
}) });
}, [id, t]); }, [id, t]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting) { if (isSubmitting) {
return return;
} }
if (!title) { if (!title) {
setError(t("Title cannot be empty")) setError(t("Title cannot be empty"));
return return;
} }
for (let i = 0; i < altTitles.length; i++) { for (let i = 0; i < altTitles.length; i++) {
if (!altTitles[i]) { if (!altTitles[i]) {
setError(t("Alternative title cannot be empty")) setError(t("Alternative title cannot be empty"));
return return;
} }
} }
if (!tags || tags.length === 0) { if (!tags || tags.length === 0) {
setError(t("At least one tag required")) setError(t("At least one tag required"));
return return;
} }
if (!article) { if (!article) {
setError(t("Description cannot be empty")) setError(t("Description cannot be empty"));
return return;
} }
setSubmitting(true) setSubmitting(true);
const res = await network.editResource(id, { const res = await network.editResource(id, {
title: title, title: title,
alternative_titles: altTitles, alternative_titles: altTitles,
tags: tags.map((tag) => tag.id), tags: tags.map((tag) => tag.id),
article: article, article: article,
images: images, images: images,
}) });
if (res.success) { if (res.success) {
setSubmitting(false) setSubmitting(false);
navigate("/resources/" + id.toString(), { replace: true }) navigate("/resources/" + id.toString(), { replace: true });
} else { } else {
setSubmitting(false) setSubmitting(false);
setError(res.message) setError(res.message);
}
} }
};
if (isNaN(id)) { 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) { 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) { if (isLoading) {
return <Loading/> return <Loading />;
} }
return <ImageDrapArea onUploaded={(images) => { return (
setImages((prev) => ([...prev, ...images])); <ImageDrapArea
}}> onUploaded={(images) => {
setImages((prev) => [...prev, ...images]);
}}
>
<div className={"p-4"}> <div className={"p-4"}>
<h1 className={"text-2xl font-bold my-4"}>{t("Edit Resource")}</h1> <h1 className={"text-2xl font-bold my-4"}>{t("Edit Resource")}</h1>
<div role="alert" className="alert alert-info mb-2 alert-dash"> <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> <span>{t("All information can be modified after publishing")}</span>
</div> </div>
<p className={"my-1"}>{t("Title")}</p> <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> <div className={"h-4"}></div>
<p className={"my-1"}>{t("Alternative Titles")}</p> <p className={"my-1"}>{t("Alternative Titles")}</p>
{ {altTitles.map((title, index) => {
altTitles.map((title, index) => { return (
return <div key={index} className={"flex items-center my-2"}> <div key={index} className={"flex items-center my-2"}>
<input type="text" className="input w-full" value={title} onChange={(e) => { <input
const newAltTitles = [...altTitles] type="text"
newAltTitles[index] = e.target.value className="input w-full"
setAltTitles(newAltTitles) value={title}
}} /> onChange={(e) => {
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => { const newAltTitles = [...altTitles];
const newAltTitles = [...altTitles] newAltTitles[index] = e.target.value;
newAltTitles.splice(index, 1) setAltTitles(newAltTitles);
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} /> <MdDelete size={24} />
</button> </button>
</div> </div>
}) );
} })}
<button className={"btn my-2"} type={"button"} onClick={() => { <button
setAltTitles([...altTitles, ""]) className={"btn my-2"}
}}> type={"button"}
onClick={() => {
setAltTitles([...altTitles, ""]);
}}
>
<MdAdd /> <MdAdd />
{t("Add Alternative Title")} {t("Add Alternative Title")}
</button> </button>
<div className={"h-2"}></div> <div className={"h-2"}></div>
<p className={"my-1"}>{t("Tags")}</p> <p className={"my-1"}>{t("Tags")}</p>
<p className={"my-1 pb-1"}> <p className={"my-1 pb-1"}>
{ {tags.map((tag, index) => {
tags.map((tag, index) => { return (
return <span key={index} className={"badge badge-primary mr-2 text-sm"}> <span key={index} className={"badge badge-primary mr-2 text-sm"}>
{tag.name} {tag.name}
<span onClick={() => { <span
const newTags = [...tags] onClick={() => {
newTags.splice(index, 1) const newTags = [...tags];
setTags(newTags) newTags.splice(index, 1);
}}> setTags(newTags);
<MdClose size={18}/> }}
>
<MdClose size={18} />
</span> </span>
</span> </span>
}) );
} })}
</p> </p>
<div className={"flex items-center"}> <div className={"flex items-center"}>
<TagInput onAdd={(tag) => { <TagInput
onAdd={(tag) => {
setTags((prev) => { setTags((prev) => {
const existingTag = prev.find(t => t.id === tag.id); const existingTag = prev.find((t) => t.id === tag.id);
if (existingTag) { if (existingTag) {
return prev; // If the tag already exists, do not add it again return prev; // If the tag already exists, do not add it again
} }
return [...prev, tag]; return [...prev, tag];
}) });
}} /> }}
<span className={"w-4"}/> />
<QuickAddTagDialog onAdded={(tags) => { <span className={"w-4"} />
<QuickAddTagDialog
onAdded={(tags) => {
setTags((prev) => { setTags((prev) => {
const newTags = [...prev]; const newTags = [...prev];
for (const tag of tags) { 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) { if (!existingTag) {
newTags.push(tag); newTags.push(tag);
} }
} }
return newTags; return newTags;
}) });
}}/> }}
/>
</div> </div>
<div className={"h-4"}></div> <div className={"h-4"}></div>
<p className={"my-1"}>{t("Description")}</p> <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 "}> <div className={"flex items-center py-1 "}>
<MdOutlineInfo className={"inline mr-1"} /> <MdOutlineInfo className={"inline mr-1"} />
<span className={"text-sm"}>{t("Use Markdown format")}</span> <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"> <div role="alert" className="alert alert-info alert-soft my-2">
<MdOutlineInfo size={24} /> <MdOutlineInfo size={24} />
<div> <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> <p>{t("The first image will be used as the cover image")}</p>
</div> </div>
</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"}> <table className={"table"}>
<thead> <thead>
<tr> <tr>
@@ -206,52 +252,72 @@ export default function EditResourcePage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ {images.map((image, index) => {
images.map((image, index) => { return (
return <tr key={index} className={"hover"}> <tr key={index} className={"hover"}>
<td> <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>
<td>{network.getImageUrl(image)}</td>
<td> <td>
{network.getImageUrl(image)} <button
</td> className={"btn btn-square"}
<td> type={"button"}
<button className={"btn btn-square"} type={"button"} onClick={() => { onClick={() => {
const id = images[index] const id = images[index];
const newImages = [...images] const newImages = [...images];
newImages.splice(index, 1) newImages.splice(index, 1);
setImages(newImages) setImages(newImages);
network.deleteImage(id) network.deleteImage(id);
}}> }}
>
<MdDelete size={24} /> <MdDelete size={24} />
</button> </button>
</td> </td>
</tr> </tr>
}) );
} })}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className={"flex"}> <div className={"flex"}>
<SelectAndUploadImageButton onUploaded={(images) => { <SelectAndUploadImageButton
setImages((prev) => ([...prev, ...images])); onUploaded={(images) => {
}}/> setImages((prev) => [...prev, ...images]);
}}
/>
<span className={"w-4"}></span> <span className={"w-4"}></span>
<UploadClipboardImageButton onUploaded={(images) => { <UploadClipboardImageButton
setImages((prev) => ([...prev, ...images])); onUploaded={(images) => {
}}/> setImages((prev) => [...prev, ...images]);
}}
/>
</div> </div>
<div className={"h-4"}></div> <div className={"h-4"}></div>
{ {error && (
error && <div role="alert" className="alert alert-error my-2 shadow"> <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" <svg
viewBox="0 0 24 24"> xmlns="http://www.w3.org/2000/svg"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" className="h-6 w-6 shrink-0 stroke-current"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> 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> </svg>
<span>{t("Error")}: {error}</span> <span>
{t("Error")}: {error}
</span>
</div> </div>
} )}
<div className={"flex flex-row-reverse mt-4"}> <div className={"flex flex-row-reverse mt-4"}>
<button className={"btn btn-accent shadow"} onClick={handleSubmit}> <button className={"btn btn-accent shadow"} onClick={handleSubmit}>
{isSubmitting && <span className="loading loading-spinner"></span>} {isSubmitting && <span className="loading loading-spinner"></span>}
@@ -260,4 +326,5 @@ export default function EditResourcePage() {
</div> </div>
</div> </div>
</ImageDrapArea> </ImageDrapArea>
);
} }

View File

@@ -1,26 +1,26 @@
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import ResourcesView from "../components/resources_view.tsx"; import ResourcesView from "../components/resources_view.tsx";
import {network} from "../network/network.ts"; import { network } from "../network/network.ts";
import { app } from "../app.ts"; import { app } from "../app.ts";
import {RSort} from "../network/models.ts"; import { RSort } from "../network/models.ts";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {useAppContext} from "../components/AppContext.tsx"; import { useAppContext } from "../components/AppContext.tsx";
export default function HomePage() { export default function HomePage() {
useEffect(() => { useEffect(() => {
document.title = app.appName; document.title = app.appName;
}, []) }, []);
const {t} = useTranslation() const { t } = useTranslation();
const appContext = useAppContext() const appContext = useAppContext();
const [order, setOrder] = useState(() => { const [order, setOrder] = useState(() => {
if (appContext && appContext.get("home_page_order") !== undefined) { if (appContext && appContext.get("home_page_order") !== undefined) {
return appContext.get("home_page_order"); return appContext.get("home_page_order");
} }
return RSort.TimeDesc; return RSort.TimeDesc;
}) });
useEffect(() => { useEffect(() => {
if (appContext && order !== RSort.TimeDesc) { if (appContext && order !== RSort.TimeDesc) {
@@ -28,9 +28,13 @@ export default function HomePage() {
} }
}, [appContext, order]); }, [appContext, order]);
return <> return (
<>
<div className={"flex p-4 items-center"}> <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; const value = e.currentTarget.value;
if (value === "0") { if (value === "0") {
setOrder(RSort.TimeAsc); setOrder(RSort.TimeAsc);
@@ -45,7 +49,8 @@ export default function HomePage() {
} else if (value === "5") { } else if (value === "5") {
setOrder(RSort.DownloadsDesc); setOrder(RSort.DownloadsDesc);
} }
}}> }}
>
<option disabled>{t("Select a Order")}</option> <option disabled>{t("Select a Order")}</option>
<option value="0">{t("Time Ascending")}</option> <option value="0">{t("Time Ascending")}</option>
<option value="1">{t("Time Descending")}</option> <option value="1">{t("Time Descending")}</option>
@@ -61,4 +66,5 @@ export default function HomePage() {
loader={(page) => network.getResources(page, order)} loader={(page) => network.getResources(page, order)}
/> />
</> </>
);
} }

View File

@@ -1,11 +1,11 @@
import {FormEvent, useEffect, useState} from "react"; import { FormEvent, useEffect, useState } from "react";
import {network} from "../network/network.ts"; import { network } from "../network/network.ts";
import {app} from "../app.ts"; import { app } from "../app.ts";
import {useNavigate} from "react-router"; import { useNavigate } from "react-router";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
export default function LoginPage() { export default function LoginPage() {
const {t} = useTranslation(); const { t } = useTranslation();
const [isLoading, setLoading] = useState(false); const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -25,7 +25,7 @@ export default function LoginPage() {
app.user = res.data!; app.user = res.data!;
app.token = res.data!.token; app.token = res.data!.token;
app.saveData(); app.saveData();
navigate("/", {replace: true}); navigate("/", { replace: true });
} else { } else {
setError(res.message); setError(res.message);
setLoading(false); setLoading(false);
@@ -34,40 +34,69 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
document.title = t("Login"); 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"}> <div className={"w-96 card card-border bg-base-100 border-base-300"}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div className={"card-body"}> <div className={"card-body"}>
<h1 className={"text-2xl font-bold"}>{t("Login")}</h1> <h1 className={"text-2xl font-bold"}>{t("Login")}</h1>
{error && <div role="alert" className="alert alert-error my-2"> {error && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none" <div role="alert" className="alert alert-error my-2">
viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" xmlns="http://www.w3.org/2000/svg"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/> 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> </svg>
<span>{error}</span> <span>{error}</span>
</div>} </div>
)}
<fieldset className="fieldset w-full"> <fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Username")}</legend> <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>
<fieldset className="fieldset w-full"> <fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Password")}</legend> <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>
<button className={"btn my-4 btn-primary"} type={"submit"}> <button className={"btn my-4 btn-primary"} type={"submit"}>
{isLoading && <span className="loading loading-spinner"></span>} {isLoading && <span className="loading loading-spinner"></span>}
{t("Continue")} {t("Continue")}
</button> </button>
<button className="btn" type={"button"} onClick={() => { <button
navigate("/register", {replace: true}); className="btn"
}}> type={"button"}
onClick={() => {
navigate("/register", { replace: true });
}}
>
{t("Don't have an account? Register")} {t("Don't have an account? Register")}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
);
} }

View File

@@ -3,7 +3,11 @@ import { app } from "../app";
import { ErrorAlert } from "../components/alert"; import { ErrorAlert } from "../components/alert";
import { network } from "../network/network"; import { network } from "../network/network";
import { ReactNode, useState } from "react"; 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 Button from "../components/button";
import showToast from "../components/toast"; import showToast from "../components/toast";
import { useNavigator } from "../components/navigator"; import { useNavigator } from "../components/navigator";
@@ -13,26 +17,44 @@ export function ManageMePage() {
const { t } = useTranslation(); const { t } = useTranslation();
if (!app.user) { 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 /> <ChangeAvatarDialog />
<ChangeUsernameDialog /> <ChangeUsernameDialog />
<ChangePasswordDialog /> <ChangePasswordDialog />
<ChangeBioDialog /> <ChangeBioDialog />
</div>; </div>
);
} }
function ListTile({ title, icon, onClick }: { title: string, icon: ReactNode, onClick: () => void }) { function ListTile({
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}> 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"> <div className="flex flex-row items-center">
<span className="text-2xl"> <span className="text-2xl">{icon}</span>
{icon}
</span>
<span className="ml-2">{title}</span> <span className="ml-2">{title}</span>
</div> </div>
</div> </div>
);
} }
function ChangeAvatarDialog() { function ChangeAvatarDialog() {
@@ -73,28 +95,47 @@ function ChangeAvatarDialog() {
showToast({ showToast({
message: t("Avatar changed successfully"), message: t("Avatar changed successfully"),
type: "success", type: "success",
}) });
const dialog = document.getElementById("change_avatar_dialog") as HTMLDialogElement; const dialog = document.getElementById(
"change_avatar_dialog",
) as HTMLDialogElement;
if (dialog) { if (dialog) {
dialog.close(); dialog.close();
} }
} }
} };
return <> return (
<ListTile icon={<MdOutlineAccountCircle />} title={t("Change Avatar")} onClick={() => { <>
const dialog = document.getElementById("change_avatar_dialog") as HTMLDialogElement; <ListTile
icon={<MdOutlineAccountCircle />}
title={t("Change Avatar")}
onClick={() => {
const dialog = document.getElementById(
"change_avatar_dialog",
) as HTMLDialogElement;
if (dialog) { if (dialog) {
dialog.showModal(); dialog.showModal();
} }
}} /> }}
/>
<dialog id="change_avatar_dialog" className="modal"> <dialog id="change_avatar_dialog" className="modal">
<div className="modal-box"> <div className="modal-box">
<h3 className="font-bold text-lg">{t("Change Avatar")}</h3> <h3 className="font-bold text-lg">{t("Change Avatar")}</h3>
<div className="h-48 flex items-center justify-center"> <div className="h-48 flex items-center justify-center">
<div className="avatar"> <div className="avatar">
<div className="w-28 rounded-full cursor-pointer" onClick={selectAvatar}> <div
<img src={avatar ? URL.createObjectURL(avatar) : network.getUserAvatar(app.user!)} alt={"avatar"} /> className="w-28 rounded-full cursor-pointer"
onClick={selectAvatar}
>
<img
src={
avatar
? URL.createObjectURL(avatar)
: network.getUserAvatar(app.user!)
}
alt={"avatar"}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -103,11 +144,19 @@ function ChangeAvatarDialog() {
<form method="dialog"> <form method="dialog">
<Button>{t("Close")}</Button> <Button>{t("Close")}</Button>
</form> </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>
</div> </div>
</dialog> </dialog>
</> </>
);
} }
function ChangeUsernameDialog() { function ChangeUsernameDialog() {
@@ -135,7 +184,9 @@ function ChangeUsernameDialog() {
message: t("Username changed successfully"), message: t("Username changed successfully"),
type: "success", type: "success",
}); });
const dialog = document.getElementById("change_username_dialog") as HTMLDialogElement; const dialog = document.getElementById(
"change_username_dialog",
) as HTMLDialogElement;
if (dialog) { if (dialog) {
dialog.close(); dialog.close();
} }
@@ -144,20 +195,25 @@ function ChangeUsernameDialog() {
} }
}; };
return <> return (
<ListTile icon={<MdOutlineEditNote />} title={t("Change Username")} onClick={() => { <>
const dialog = document.getElementById("change_username_dialog") as HTMLDialogElement; <ListTile
icon={<MdOutlineEditNote />}
title={t("Change Username")}
onClick={() => {
const dialog = document.getElementById(
"change_username_dialog",
) as HTMLDialogElement;
if (dialog) { if (dialog) {
dialog.showModal(); dialog.showModal();
} }
}} /> }}
/>
<dialog id="change_username_dialog" className="modal"> <dialog id="change_username_dialog" className="modal">
<div className="modal-box"> <div className="modal-box">
<h3 className="font-bold text-lg">{t("Change Username")}</h3> <h3 className="font-bold text-lg">{t("Change Username")}</h3>
<div className="input mt-4 w-full"> <div className="input mt-4 w-full">
<label className="label"> <label className="label">{t("New Username")}</label>
{t("New Username")}
</label>
<input <input
type="text" type="text"
placeholder={t("Enter new username")} placeholder={t("Enter new username")}
@@ -181,7 +237,8 @@ function ChangeUsernameDialog() {
</div> </div>
</div> </div>
</dialog> </dialog>
</>; </>
);
} }
function ChangePasswordDialog() { function ChangePasswordDialog() {
@@ -226,7 +283,9 @@ function ChangePasswordDialog() {
type: "success", type: "success",
}); });
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement; const dialog = document.getElementById(
"change_password_dialog",
) as HTMLDialogElement;
if (dialog) { if (dialog) {
dialog.close(); dialog.close();
} }
@@ -239,13 +298,20 @@ function ChangePasswordDialog() {
} }
}; };
return <> return (
<ListTile icon={<MdLockOutline />} title={t("Change Password")} onClick={() => { <>
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement; <ListTile
icon={<MdLockOutline />}
title={t("Change Password")}
onClick={() => {
const dialog = document.getElementById(
"change_password_dialog",
) as HTMLDialogElement;
if (dialog) { if (dialog) {
dialog.showModal(); dialog.showModal();
} }
}} /> }}
/>
<dialog id="change_password_dialog" className="modal"> <dialog id="change_password_dialog" className="modal">
<div className="modal-box"> <div className="modal-box">
<h3 className="font-bold text-lg mb-2">{t("Change Password")}</h3> <h3 className="font-bold text-lg mb-2">{t("Change Password")}</h3>
@@ -273,7 +339,9 @@ function ChangePasswordDialog() {
</fieldset> </fieldset>
<fieldset className="fieldset w-full"> <fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Confirm New Password")}</legend> <legend className="fieldset-legend">
{t("Confirm New Password")}
</legend>
<input <input
type="password" type="password"
placeholder={t("Confirm new password")} placeholder={t("Confirm new password")}
@@ -300,7 +368,8 @@ function ChangePasswordDialog() {
</div> </div>
</div> </div>
</dialog> </dialog>
</>; </>
);
} }
function ChangeBioDialog() { function ChangeBioDialog() {
@@ -329,7 +398,9 @@ function ChangeBioDialog() {
message: t("Bio changed successfully"), message: t("Bio changed successfully"),
type: "success", type: "success",
}); });
const dialog = document.getElementById("change_bio_dialog") as HTMLDialogElement; const dialog = document.getElementById(
"change_bio_dialog",
) as HTMLDialogElement;
if (dialog) { if (dialog) {
dialog.close(); dialog.close();
} }
@@ -338,17 +409,28 @@ function ChangeBioDialog() {
} }
}; };
return <> return (
<ListTile icon={<MdOutlineEditNote />} title={t("Change Bio")} onClick={() => { <>
const dialog = document.getElementById("change_bio_dialog") as HTMLDialogElement; <ListTile
icon={<MdOutlineEditNote />}
title={t("Change Bio")}
onClick={() => {
const dialog = document.getElementById(
"change_bio_dialog",
) as HTMLDialogElement;
if (dialog) { if (dialog) {
dialog.showModal(); dialog.showModal();
} }
}} /> }}
/>
<dialog id="change_bio_dialog" className="modal"> <dialog id="change_bio_dialog" className="modal">
<div className="modal-box"> <div className="modal-box">
<h3 className="font-bold text-lg">{t("Change Bio")}</h3> <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"} />} {error && <ErrorAlert message={error} className={"mt-4"} />}
<div className="modal-action"> <div className="modal-action">
<form method="dialog"> <form method="dialog">
@@ -365,5 +447,6 @@ function ChangeBioDialog() {
</div> </div>
</div> </div>
</dialog> </dialog>
</>; </>
);
} }

View File

@@ -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 { ReactNode, useEffect, useState } from "react";
import StorageView from "./manage_storage_page.tsx"; import StorageView from "./manage_storage_page.tsx";
import UserView from "./manage_user_page.tsx"; import UserView from "./manage_user_page.tsx";
@@ -26,70 +31,89 @@ export default function ManagePage() {
useEffect(() => { useEffect(() => {
document.title = t("Manage"); document.title = t("Manage");
}, []) }, []);
const buildItem = (title: string, icon: ReactNode, p: number) => { const buildItem = (title: string, icon: ReactNode, p: number) => {
return <li key={title} onClick={() => { return (
<li
key={title}
onClick={() => {
setPage(p); setPage(p);
const checkbox = document.getElementById("my-drawer-2") as HTMLInputElement; const checkbox = document.getElementById(
"my-drawer-2",
) as HTMLInputElement;
if (checkbox) { if (checkbox) {
checkbox.checked = false; 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} {icon}
<span className={"text"}> <span className={"text"}>{title}</span>
{title}
</span>
</a> </a>
</li> </li>
} );
};
const pageNames = [ const pageNames = [t("My Info"), t("Storage"), t("Users"), t("Server")];
t("My Info"),
t("Storage"),
t("Users"),
t("Server"),
]
const pageComponents = [ const pageComponents = [
<ManageMePage />, <ManageMePage />,
<StorageView />, <StorageView />,
<UserView />, <UserView />,
<ManageServerConfigPage />, <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" /> <input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
<div className="drawer-content" style={{ <div
className="drawer-content"
style={{
height: "calc(100vh - 64px)", height: "calc(100vh - 64px)",
}}> }}
>
<div className={"flex w-full h-14 items-center gap-2 px-3"}> <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} /> <MdMenu size={24} />
</label> </label>
<h1 className={"text-xl font-bold"}> <h1 className={"text-xl font-bold"}>{pageNames[page]}</h1>
{pageNames[page]}
</h1>
</div> </div>
<div> <div>{pageComponents[page]}</div>
{pageComponents[page]}
</div> </div>
</div> <div
<div className="drawer-side" style={{ className="drawer-side"
style={{
height: lg ? "calc(100vh - 64px)" : "100vh", 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"> <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"}> <h2 className={"text-lg font-bold p-4"}>{t("Manage")}</h2>
{t("Manage")}
</h2>
{buildItem(t("My Info"), <MdOutlineBadge className={"text-xl"} />, 0)} {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("Users"), <MdOutlinePerson className={"text-xl"} />, 2)}
{buildItem(t("Server"), <MdOutlineStorage className={"text-xl"} />, 3)} {buildItem(
t("Server"),
<MdOutlineStorage className={"text-xl"} />,
3,
)}
</ul> </ul>
</div> </div>
</div> </div>
);
} }

View File

@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { app } from "../app" import { app } from "../app";
import { ErrorAlert, InfoAlert } from "../components/alert" import { ErrorAlert, InfoAlert } from "../components/alert";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ServerConfig } from "../network/models"; import { ServerConfig } from "../network/models";
import Loading from "../components/loading"; import Loading from "../components/loading";
import Input, {TextArea} from "../components/input"; import Input, { TextArea } from "../components/input";
import { network } from "../network/network"; import { network } from "../network/network";
import showToast from "../components/toast"; import showToast from "../components/toast";
import Button from "../components/button"; import Button from "../components/button";
@@ -24,21 +24,31 @@ export default function ManageServerConfigPage() {
showToast({ showToast({
message: res.message, message: res.message,
type: "error", type: "error",
}) });
} }
}) });
}, []); }, []);
if (!app.user) { 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) { 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) { if (config == null) {
return <Loading /> return <Loading />;
} }
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -62,40 +72,107 @@ export default function ManageServerConfigPage() {
setIsLoading(false); setIsLoading(false);
}; };
return <form className="px-4 pb-4" onSubmit={handleSubmit}> return (
<Input type="number" value={config.max_uploading_size_in_mb.toString()} label="Max uploading size (MB)" onChange={(e) => { <form className="px-4 pb-4" onSubmit={handleSubmit}>
setConfig({...config, max_uploading_size_in_mb: parseInt(e.target.value) }) <Input
}}></Input> type="number"
<Input type="number" value={config.max_file_size_in_mb.toString()} label="Max file size (MB)" onChange={(e) => { value={config.max_uploading_size_in_mb.toString()}
setConfig({...config, max_file_size_in_mb: parseInt(e.target.value) }) label="Max uploading size (MB)"
}}></Input> onChange={(e) => {
<Input type="number" value={config.max_downloads_per_day_for_single_ip.toString()} label="Max downloads per day for single IP" onChange={(e) => { setConfig({
setConfig({...config, max_downloads_per_day_for_single_ip: parseInt(e.target.value) }) ...config,
}}></Input> 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"> <fieldset className="fieldset w-full">
<legend className="fieldset-legend">Allow register</legend> <legend className="fieldset-legend">Allow register</legend>
<input type="checkbox" checked={config.allow_register} className="toggle-primary toggle" onChange={(e) => { <input
setConfig({ ...config, allow_register: e.target.checked }) type="checkbox"
}} /> checked={config.allow_register}
className="toggle-primary toggle"
onChange={(e) => {
setConfig({ ...config, allow_register: e.target.checked });
}}
/>
</fieldset> </fieldset>
<Input type="text" value={config.server_name} label="Server name" onChange={(e) => { <Input
setConfig({...config, server_name: e.target.value }) type="text"
}}></Input> value={config.server_name}
<Input type="text" value={config.server_description} label="Server description" onChange={(e) => { label="Server name"
setConfig({...config, server_description: e.target.value }) onChange={(e) => {
}}></Input> setConfig({ ...config, server_name: e.target.value });
<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> <Input
<Input type="text" value={config.cloudflare_turnstile_secret_key} label="Cloudflare Turnstile Secret Key" onChange={(e) => { type="text"
setConfig({...config, cloudflare_turnstile_secret_key: e.target.value }) value={config.server_description}
}}></Input> label="Server description"
<TextArea value={config.site_info} onChange={(e) => { onChange={(e) => {
setConfig({...config, site_info: e.target.value }) setConfig({ ...config, server_description: 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>
<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"> <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> </div>
</form> </form>
);
} }

View File

@@ -23,36 +23,46 @@ export default function StorageView() {
} else { } else {
showToast({ showToast({
message: response.message, message: response.message,
type: "error" type: "error",
}); });
} }
}) });
}, []); }, []);
if (!app.user) { 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) { 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) { if (storages == null) {
return <Loading /> return <Loading />;
} }
const updateStorages = async () => { const updateStorages = async () => {
setStorages(null) setStorages(null);
const response = await network.listStorages(); const response = await network.listStorages();
if (response.success) { if (response.success) {
setStorages(response.data!); setStorages(response.data!);
} else { } else {
showToast({ showToast({
message: response.message, message: response.message,
type: "error" type: "error",
}); });
} }
} };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (loadingId != null) { if (loadingId != null) {
@@ -68,24 +78,36 @@ export default function StorageView() {
} else { } else {
showToast({ showToast({
message: response.message, message: response.message,
type: "error" type: "error",
}); });
} }
setLoadingId(null); setLoadingId(null);
} };
return <> 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" <div
className="h-6 w-6 shrink-0 stroke-current"> role="alert"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" className={`alert alert-info alert-outline ${storages.length !== 0 && "hidden"} mx-4 mb-4`}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> >
<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> </svg>
<span> <span>{t("No storage found. Please create a new storage.")}</span>
{t("No storage found. Please create a new storage.")}
</span>
</div> </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"}> <table className={"table"}>
<thead> <thead>
<tr> <tr>
@@ -96,38 +118,57 @@ export default function StorageView() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ {storages.map((s) => {
storages.map((s) => { return (
return <tr key={s.id} className={"hover"}> <tr key={s.id} className={"hover"}>
<td>{s.name}</td>
<td>{new Date(s.createdAt).toLocaleString()}</td>
<td> <td>
{s.name} {(s.currentSize / 1024 / 1024).toFixed(2)} /{" "}
{s.maxSize / 1024 / 1024} MB
</td> </td>
<td> <td>
{(new Date(s.createdAt)).toLocaleString()} <button
</td> className={"btn btn-square"}
<td> type={"button"}
{(s.currentSize / 1024 / 1024).toFixed(2)} / {s.maxSize / 1024 / 1024} MB onClick={() => {
</td> const dialog = document.getElementById(
<td> `confirm_delete_dialog_${s.id}`,
<button className={"btn btn-square"} type={"button"} onClick={() => { ) as HTMLDialogElement;
const dialog = document.getElementById(`confirm_delete_dialog_${s.id}`) as HTMLDialogElement;
dialog.showModal(); 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> </button>
<dialog id={`confirm_delete_dialog_${s.id}`} className="modal"> <dialog
id={`confirm_delete_dialog_${s.id}`}
className="modal"
>
<div className="modal-box"> <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"> <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> </p>
<div className="modal-action"> <div className="modal-action">
<form method="dialog"> <form method="dialog">
<button className="btn">{t("Cancel")}</button> <button className="btn">{t("Cancel")}</button>
</form> </form>
<button className="btn btn-error" onClick={() => { <button
className="btn btn-error"
onClick={() => {
handleDelete(s.id); handleDelete(s.id);
}}> }}
>
{t("Delete")} {t("Delete")}
</button> </button>
</div> </div>
@@ -135,8 +176,8 @@ export default function StorageView() {
</dialog> </dialog>
</td> </td>
</tr> </tr>
}) );
} })}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -144,6 +185,7 @@ export default function StorageView() {
<NewStorageDialog onAdded={updateStorages} /> <NewStorageDialog onAdded={updateStorages} />
</div> </div>
</> </>
);
} }
enum StorageType { enum StorageType {
@@ -183,14 +225,33 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
setIsLoading(false); setIsLoading(false);
return; 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) { } 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")); setError(t("All fields are required"));
setIsLoading(false); setIsLoading(false);
return; 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) { if (response!.success) {
@@ -198,19 +259,27 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
message: t("Storage created successfully"), message: t("Storage created successfully"),
}); });
onAdded(); onAdded();
const dialog = document.getElementById("new_storage_dialog") as HTMLDialogElement; const dialog = document.getElementById(
"new_storage_dialog",
) as HTMLDialogElement;
dialog.close(); dialog.close();
} else { } else {
setError(response!.message); setError(response!.message);
} }
setIsLoading(false); setIsLoading(false);
} };
return <> return (
<button className="btn" onClick={() => { <>
const dialog = document.getElementById("new_storage_dialog") as HTMLDialogElement; <button
className="btn"
onClick={() => {
const dialog = document.getElementById(
"new_storage_dialog",
) as HTMLDialogElement;
dialog.showModal(); dialog.showModal();
}}> }}
>
<MdAdd /> <MdAdd />
{t("New Storage")} {t("New Storage")}
</button> </button>
@@ -220,36 +289,63 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
<p className={"text-sm font-bold p-2"}>{t("Type")}</p> <p className={"text-sm font-bold p-2"}>{t("Type")}</p>
<form className="filter mb-2"> <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); 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); 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); setStorageType(StorageType.s3);
}} /> }}
/>
</form> </form>
{ {storageType === StorageType.local && (
storageType === StorageType.local && <> <>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Name")} {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({ setParams({
...params, ...params,
name: e.target.value, name: e.target.value,
}) });
}} /> }}
/>
</label> </label>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Path")} {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({ setParams({
...params, ...params,
path: e.target.value, path: e.target.value,
}) });
}} /> }}
/>
</label> </label>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Max Size (MB)")} {t("Max Size (MB)")}
@@ -263,68 +359,99 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
setParams({ setParams({
...params, ...params,
maxSizeInMB: parseInt(e.target.value), maxSizeInMB: parseInt(e.target.value),
}) });
}} }}
/> />
</label> </label>
</> </>
} )}
{ {storageType === StorageType.s3 && (
storageType === StorageType.s3 && <> <>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Name")} {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({ setParams({
...params, ...params,
name: e.target.value, name: e.target.value,
}) });
}} /> }}
/>
</label> </label>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Endpoint")} {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({ setParams({
...params, ...params,
endPoint: e.target.value, endPoint: e.target.value,
}) });
}} /> }}
/>
</label> </label>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Access Key ID")} {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({ setParams({
...params, ...params,
accessKeyID: e.target.value, accessKeyID: e.target.value,
}) });
}} /> }}
/>
</label> </label>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Secret Access Key")} {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({ setParams({
...params, ...params,
secretAccessKey: e.target.value, secretAccessKey: e.target.value,
}) });
}} /> }}
/>
</label> </label>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Bucket Name")} {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({ setParams({
...params, ...params,
bucketName: e.target.value, bucketName: e.target.value,
}) });
}} /> }}
/>
</label> </label>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Domain")} {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({ setParams({
...params, ...params,
domain: e.target.value, domain: e.target.value,
}) });
}} /> }}
/>
</label> </label>
<label className="input w-full my-2"> <label className="input w-full my-2">
{t("Max Size (MB)")} {t("Max Size (MB)")}
@@ -338,12 +465,12 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
setParams({ setParams({
...params, ...params,
maxSizeInMB: parseInt(e.target.value), maxSizeInMB: parseInt(e.target.value),
}) });
}} }}
/> />
</label> </label>
</> </>
} )}
{error !== "" && <ErrorAlert message={error} className={"my-2"} />} {error !== "" && <ErrorAlert message={error} className={"my-2"} />}
@@ -351,12 +478,21 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
<form method="dialog"> <form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button> <button className="btn btn-ghost">{t("Close")}</button>
</form> </form>
<button className={"btn btn-primary"} onClick={handleSubmit} type={"button"}> <button
{isLoading && <span className={"loading loading-spinner loading-sm mr-2"}></span>} className={"btn btn-primary"}
onClick={handleSubmit}
type={"button"}
>
{isLoading && (
<span
className={"loading loading-spinner loading-sm mr-2"}
></span>
)}
{t("Submit")} {t("Submit")}
</button> </button>
</div> </div>
</div> </div>
</dialog> </dialog>
</> </>
);
} }

View File

@@ -19,35 +19,72 @@ export default function UserView() {
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
if (!app.user) { 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) { 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"}> <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(); e.preventDefault();
setPage(0); 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); setSearchKeyword(input.value);
}}> }}
>
<label className="input"> <label className="input">
<MdSearch size={20} className="opacity-50" /> <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> </label>
</form> </form>
</div> </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"}> <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> </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 { t } = useTranslation();
const [users, setUsers] = useState<User[] | null>(null); const [users, setUsers] = useState<User[] | null>(null);
@@ -61,7 +98,7 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
showToast({ showToast({
type: "error", type: "error",
message: response.message, message: response.message,
}) });
} }
}); });
} else { } else {
@@ -73,7 +110,7 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
showToast({ showToast({
type: "error", type: "error",
message: response.message, message: response.message,
}) });
} }
}); });
} }
@@ -92,7 +129,10 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
return <Loading />; 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"}> <table className={"table"}>
<thead> <thead>
<tr> <tr>
@@ -104,17 +144,16 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ {users.map((u) => {
users.map((u) => { return <UserRow key={u.id} user={u} onChanged={handleChanged} />;
return <UserRow key={u.id} user={u} onChanged={handleChanged} /> })}
})
}
</tbody> </tbody>
</table> </table>
</div> </div>
);
} }
function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) { function UserRow({ user, onChanged }: { user: User; onChanged: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -139,7 +178,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
}); });
} }
setIsLoading(false); setIsLoading(false);
} };
const handleSetAdmin = async () => { const handleSetAdmin = async () => {
if (isLoading) { if (isLoading) {
@@ -160,7 +199,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
}); });
} }
setIsLoading(false); setIsLoading(false);
} };
const handleSetUser = async () => { const handleSetUser = async () => {
if (isLoading) { if (isLoading) {
@@ -181,7 +220,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
}); });
} }
setIsLoading(false); setIsLoading(false);
} };
const handleSetUploadPermission = async () => { const handleSetUploadPermission = async () => {
if (isLoading) { if (isLoading) {
@@ -202,7 +241,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
}); });
} }
setIsLoading(false); setIsLoading(false);
} };
const handleRemoveUploadPermission = async () => { const handleRemoveUploadPermission = async () => {
if (isLoading) { if (isLoading) {
@@ -223,50 +262,80 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
}); });
} }
setIsLoading(false); setIsLoading(false);
} };
return <tr key={user.id} className={"hover"}> return (
<td> <tr key={user.id} className={"hover"}>
{user.username} <td>{user.username}</td>
</td> <td>{new Date(user.created_at).toLocaleDateString()}</td>
<td> <td>{user.is_admin ? t("Yes") : t("No")}</td>
{(new Date(user.created_at)).toLocaleDateString()} <td>{user.can_upload ? t("Yes") : t("No")}</td>
</td>
<td>
{user.is_admin ? t("Yes") : t("No")}
</td>
<td>
{user.can_upload ? t("Yes") : t("No")}
</td>
<td> <td>
<div className="dropdown dropdown-bottom dropdown-end"> <div className="dropdown dropdown-bottom dropdown-end">
<button ref={buttonRef} className="btn btn-square m-1" onClick={() => { <button
showPopup(<ul className="menu bg-base-100 rounded-box z-1 w-64 p-2 shadow-sm"> ref={buttonRef}
<h4 className="text-sm font-bold px-3 py-1 text-primary">{t("Actions")}</h4> className="btn btn-square m-1"
<PopupMenuItem onClick={() => { onClick={() => {
const dialog = document.getElementById(`delete_user_dialog_${user.id}`) as HTMLDialogElement; 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(); dialog.showModal();
}}> }}
>
<a>{t("Delete")}</a> <a>{t("Delete")}</a>
</PopupMenuItem> </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 ? ( {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} ) : null}
</ul>, buttonRef.current!); </ul>,
}}> buttonRef.current!,
{isLoading );
? <span className="loading loading-spinner loading-sm"></span> }}
: <MdMoreHoriz size={20} className="opacity-50" />} >
{isLoading ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<MdMoreHoriz size={20} className="opacity-50" />
)}
</button> </button>
<dialog id={`delete_user_dialog_${user.id}`} className="modal"> <dialog id={`delete_user_dialog_${user.id}`} className="modal">
<div className="modal-box"> <div className="modal-box">
<h3 className="font-bold text-lg">{t("Delete User")}</h3> <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"> <div className="modal-action">
<form method="dialog"> <form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button> <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> </form>
</div> </div>
</div> </div>
@@ -274,4 +343,5 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
</div> </div>
</td> </td>
</tr> </tr>
);
} }

View File

@@ -1,84 +1,101 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import {MdAdd, MdClose, MdDelete, MdOutlineInfo} from "react-icons/md"; import { MdAdd, MdClose, MdDelete, MdOutlineInfo } from "react-icons/md";
import { Tag } from "../network/models.ts"; import { Tag } from "../network/models.ts";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { app } from "../app.ts"; import { app } from "../app.ts";
import { ErrorAlert } from "../components/alert.tsx"; import { ErrorAlert } from "../components/alert.tsx";
import {useAppContext} from "../components/AppContext.tsx"; import { useAppContext } from "../components/AppContext.tsx";
import TagInput, {QuickAddTagDialog} from "../components/tag_input.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() { export default function PublishPage() {
const [title, setTitle] = useState<string>("") const [title, setTitle] = useState<string>("");
const [altTitles, setAltTitles] = useState<string[]>([]) const [altTitles, setAltTitles] = useState<string[]>([]);
const [tags, setTags] = useState<Tag[]>([]) const [tags, setTags] = useState<Tag[]>([]);
const [article, setArticle] = useState<string>("") const [article, setArticle] = useState<string>("");
const [images, setImages] = useState<number[]>([]) const [images, setImages] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [isSubmitting, setSubmitting] = useState(false) const [isSubmitting, setSubmitting] = useState(false);
const navigate = useNavigate() const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const appContext = useAppContext() const appContext = useAppContext();
useEffect(() => { useEffect(() => {
document.title = t("Publish Resource"); document.title = t("Publish Resource");
}, [t]) }, [t]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting) { if (isSubmitting) {
return return;
} }
if (!title) { if (!title) {
setError(t("Title cannot be empty")) setError(t("Title cannot be empty"));
return return;
} }
for (let i = 0; i < altTitles.length; i++) { for (let i = 0; i < altTitles.length; i++) {
if (!altTitles[i]) { if (!altTitles[i]) {
setError(t("Alternative title cannot be empty")) setError(t("Alternative title cannot be empty"));
return return;
} }
} }
if (!tags || tags.length === 0) { if (!tags || tags.length === 0) {
setError(t("At least one tag required")) setError(t("At least one tag required"));
return return;
} }
if (!article) { if (!article) {
setError(t("Description cannot be empty")) setError(t("Description cannot be empty"));
return return;
} }
setSubmitting(true) setSubmitting(true);
const res = await network.createResource({ const res = await network.createResource({
title: title, title: title,
alternative_titles: altTitles, alternative_titles: altTitles,
tags: tags.map((tag) => tag.id), tags: tags.map((tag) => tag.id),
article: article, article: article,
images: images, images: images,
}) });
if (res.success) { if (res.success) {
setSubmitting(false) setSubmitting(false);
appContext.clear(); appContext.clear();
navigate("/resources/" + res.data!, { replace: true }) navigate("/resources/" + res.data!, { replace: true });
} else { } else {
setSubmitting(false) setSubmitting(false);
setError(res.message) setError(res.message);
}
} }
};
if (!app.user) { 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()) { 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) => { return (
setImages((prev) => ([...prev, ...images])); <ImageDrapArea
}}> onUploaded={(images) => {
setImages((prev) => [...prev, ...images]);
}}
>
<div className={"p-4"}> <div className={"p-4"}>
<h1 className={"text-2xl font-bold my-4"}>{t("Publish Resource")}</h1> <h1 className={"text-2xl font-bold my-4"}>{t("Publish Resource")}</h1>
<div role="alert" className="alert alert-info mb-2 alert-dash"> <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> <span>{t("All information can be modified after publishing")}</span>
</div> </div>
<p className={"my-1"}>{t("Title")}</p> <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> <div className={"h-4"}></div>
<p className={"my-1"}>{t("Alternative Titles")}</p> <p className={"my-1"}>{t("Alternative Titles")}</p>
{ {altTitles.map((title, index) => {
altTitles.map((title, index) => { return (
return <div key={index} className={"flex items-center my-2"}> <div key={index} className={"flex items-center my-2"}>
<input type="text" className="input w-full" value={title} onChange={(e) => { <input
const newAltTitles = [...altTitles] type="text"
newAltTitles[index] = e.target.value className="input w-full"
setAltTitles(newAltTitles) value={title}
}} /> onChange={(e) => {
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => { const newAltTitles = [...altTitles];
const newAltTitles = [...altTitles] newAltTitles[index] = e.target.value;
newAltTitles.splice(index, 1) setAltTitles(newAltTitles);
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} /> <MdDelete size={24} />
</button> </button>
</div> </div>
}) );
} })}
<button className={"btn my-2"} type={"button"} onClick={() => { <button
setAltTitles([...altTitles, ""]) className={"btn my-2"}
}}> type={"button"}
onClick={() => {
setAltTitles([...altTitles, ""]);
}}
>
<MdAdd /> <MdAdd />
{t("Add Alternative Title")} {t("Add Alternative Title")}
</button> </button>
<div className={"h-2"}></div> <div className={"h-2"}></div>
<p className={"my-1"}>{t("Tags")}</p> <p className={"my-1"}>{t("Tags")}</p>
<p className={"my-1 pb-1"}> <p className={"my-1 pb-1"}>
{ {tags.map((tag, index) => {
tags.map((tag, index) => { return (
return <span key={index} className={"badge badge-primary mr-2 text-sm"}> <span key={index} className={"badge badge-primary mr-2 text-sm"}>
{tag.name} {tag.name}
<span onClick={() => { <span
const newTags = [...tags] onClick={() => {
newTags.splice(index, 1) const newTags = [...tags];
setTags(newTags) newTags.splice(index, 1);
}}> setTags(newTags);
<MdClose size={18}/> }}
>
<MdClose size={18} />
</span> </span>
</span> </span>
}) );
} })}
</p> </p>
<div className={"flex items-center"}> <div className={"flex items-center"}>
<TagInput onAdd={(tag) => { <TagInput
onAdd={(tag) => {
setTags((prev) => { setTags((prev) => {
const existingTag = prev.find(t => t.id === tag.id); const existingTag = prev.find((t) => t.id === tag.id);
if (existingTag) { if (existingTag) {
return prev; // If the tag already exists, do not add it again return prev; // If the tag already exists, do not add it again
} }
return [...prev, tag]; return [...prev, tag];
}) });
}} /> }}
<span className={"w-4"}/> />
<QuickAddTagDialog onAdded={(tags) => { <span className={"w-4"} />
<QuickAddTagDialog
onAdded={(tags) => {
setTags((prev) => { setTags((prev) => {
const newTags = [...prev]; const newTags = [...prev];
for (const tag of tags) { 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) { if (!existingTag) {
newTags.push(tag); newTags.push(tag);
} }
} }
return newTags; return newTags;
}) });
}}/> }}
/>
</div> </div>
<div className={"h-4"}></div> <div className={"h-4"}></div>
<p className={"my-1"}>{t("Description")}</p> <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 "}> <div className={"flex items-center py-1 "}>
<MdOutlineInfo className={"inline mr-1"} /> <MdOutlineInfo className={"inline mr-1"} />
<span className={"text-sm"}>{t("Use Markdown format")}</span> <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"> <div role="alert" className="alert alert-info alert-soft my-2">
<MdOutlineInfo size={24} /> <MdOutlineInfo size={24} />
<div> <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> <p>{t("The first image will be used as the cover image")}</p>
</div> </div>
</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"}> <table className={"table"}>
<thead> <thead>
<tr> <tr>
@@ -181,52 +232,72 @@ export default function PublishPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ {images.map((image, index) => {
images.map((image, index) => { return (
return <tr key={index} className={"hover"}> <tr key={index} className={"hover"}>
<td> <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>
<td>{network.getImageUrl(image)}</td>
<td> <td>
{network.getImageUrl(image)} <button
</td> className={"btn btn-square"}
<td> type={"button"}
<button className={"btn btn-square"} type={"button"} onClick={() => { onClick={() => {
const id = images[index] const id = images[index];
const newImages = [...images] const newImages = [...images];
newImages.splice(index, 1) newImages.splice(index, 1);
setImages(newImages) setImages(newImages);
network.deleteImage(id) network.deleteImage(id);
}}> }}
>
<MdDelete size={24} /> <MdDelete size={24} />
</button> </button>
</td> </td>
</tr> </tr>
}) );
} })}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className={"flex"}> <div className={"flex"}>
<SelectAndUploadImageButton onUploaded={(images) => { <SelectAndUploadImageButton
setImages((prev) => ([...prev, ...images])); onUploaded={(images) => {
}}/> setImages((prev) => [...prev, ...images]);
}}
/>
<span className={"w-4"}></span> <span className={"w-4"}></span>
<UploadClipboardImageButton onUploaded={(images) => { <UploadClipboardImageButton
setImages((prev) => ([...prev, ...images])); onUploaded={(images) => {
}}/> setImages((prev) => [...prev, ...images]);
}}
/>
</div> </div>
<div className={"h-4"}></div> <div className={"h-4"}></div>
{ {error && (
error && <div role="alert" className="alert alert-error my-2 shadow"> <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" <svg
viewBox="0 0 24 24"> xmlns="http://www.w3.org/2000/svg"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" className="h-6 w-6 shrink-0 stroke-current"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /> 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> </svg>
<span>{t("Error")}: {error}</span> <span>
{t("Error")}: {error}
</span>
</div> </div>
} )}
<div className={"flex flex-row-reverse mt-4"}> <div className={"flex flex-row-reverse mt-4"}>
<button className={"btn btn-accent shadow"} onClick={handleSubmit}> <button className={"btn btn-accent shadow"} onClick={handleSubmit}>
{isSubmitting && <span className="loading loading-spinner"></span>} {isSubmitting && <span className="loading loading-spinner"></span>}
@@ -235,4 +306,5 @@ export default function PublishPage() {
</div> </div>
</div> </div>
</ImageDrapArea> </ImageDrapArea>
);
} }

View File

@@ -1,12 +1,12 @@
import {FormEvent, useEffect, useState} from "react"; import { FormEvent, useEffect, useState } from "react";
import {network} from "../network/network.ts"; import { network } from "../network/network.ts";
import {app} from "../app.ts"; import { app } from "../app.ts";
import {useNavigate} from "react-router"; import { useNavigate } from "react-router";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
import {Turnstile} from "@marsidev/react-turnstile"; import { Turnstile } from "@marsidev/react-turnstile";
export default function RegisterPage() { export default function RegisterPage() {
const {t} = useTranslation(); const { t } = useTranslation();
const [isLoading, setLoading] = useState(false); const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -37,7 +37,7 @@ export default function RegisterPage() {
app.user = res.data!; app.user = res.data!;
app.token = res.data!.token; app.token = res.data!.token;
app.saveData(); app.saveData();
navigate("/", {replace: true}); navigate("/", { replace: true });
} else { } else {
setError(res.message); setError(res.message);
setLoading(false); setLoading(false);
@@ -46,53 +46,87 @@ export default function RegisterPage() {
useEffect(() => { useEffect(() => {
document.title = t("Register"); 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"}> <div className={"w-96 card card-border bg-base-100 border-base-300"}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div className={"card-body"}> <div className={"card-body"}>
<h1 className={"text-2xl font-bold"}>{t("Register")}</h1> <h1 className={"text-2xl font-bold"}>{t("Register")}</h1>
{error && <div role="alert" className="alert alert-error my-2"> {error && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none" <div role="alert" className="alert alert-error my-2">
viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" xmlns="http://www.w3.org/2000/svg"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/> 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> </svg>
<span>{error}</span> <span>{error}</span>
</div>} </div>
)}
<fieldset className="fieldset w-full"> <fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Username")}</legend> <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>
<fieldset className="fieldset w-full"> <fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Password")}</legend> <legend className="fieldset-legend">{t("Password")}</legend>
<input type="password" className="input w-full" value={password} <input
onChange={(e) => setPassword(e.target.value)}/> type="password"
className="input w-full"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</fieldset> </fieldset>
<fieldset className="fieldset w-full"> <fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Confirm Password")}</legend> <legend className="fieldset-legend">
<input type="password" className="input w-full" value={confirmPassword} {t("Confirm Password")}
onChange={(e) => setConfirmPassword(e.target.value)}/> </legend>
<input
type="password"
className="input w-full"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</fieldset> </fieldset>
{ {app.cloudflareTurnstileSiteKey && (
app.cloudflareTurnstileSiteKey && <Turnstile <Turnstile
siteKey={app.cloudflareTurnstileSiteKey} siteKey={app.cloudflareTurnstileSiteKey}
onSuccess={setCfToken} onSuccess={setCfToken}
onExpire={() => setCfToken("")} onExpire={() => setCfToken("")}
/> />
} )}
<button className={"btn my-4 btn-primary"} type={"submit"}> <button className={"btn my-4 btn-primary"} type={"submit"}>
{isLoading && <span className="loading loading-spinner"></span>} {isLoading && <span className="loading loading-spinner"></span>}
{t("Continue")} {t("Continue")}
</button> </button>
<button className="btn" type={"button"} onClick={() => { <button
navigate("/login", {replace: true}); className="btn"
}}> type={"button"}
onClick={() => {
navigate("/login", { replace: true });
}}
>
{t("Already have an account? Login")} {t("Already have an account? Login")}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,35 @@
import {useSearchParams} from "react-router"; import { useSearchParams } from "react-router";
import {network} from "../network/network.ts"; import { network } from "../network/network.ts";
import ResourcesView from "../components/resources_view.tsx"; import ResourcesView from "../components/resources_view.tsx";
import {useEffect} from "react"; import { useEffect } from "react";
import {useTranslation} from "react-i18next"; import { useTranslation } from "react-i18next";
export default function SearchPage() { export default function SearchPage() {
const [params, _] = useSearchParams() const [params, _] = useSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const keyword = params.get("keyword") const keyword = params.get("keyword");
useEffect(() => { useEffect(() => {
document.title = t("Search") + ": " + (keyword || ""); document.title = t("Search") + ": " + (keyword || "");
}, []) }, []);
if (keyword === null || 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> <span>{t("Enter a search keyword to continue")}</span>
</div> </div>
);
} }
return <div key={keyword}> return (
<h1 className={"text-2xl px-4 pt-4 font-bold my-2"}>{t("Search")}: {keyword}</h1> <div key={keyword}>
<ResourcesView loader={(page) => network.searchResources(keyword, page)}></ResourcesView> <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> </div>
);
} }

View File

@@ -2,18 +2,18 @@ import { useParams } from "react-router";
import { ErrorAlert } from "../components/alert.tsx"; import { ErrorAlert } from "../components/alert.tsx";
import ResourcesView from "../components/resources_view.tsx"; import ResourcesView from "../components/resources_view.tsx";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {Tag} from "../network/models.ts"; import { Tag } from "../network/models.ts";
import Button from "../components/button.tsx"; import Button from "../components/button.tsx";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import {app} from "../app.ts"; import { app } from "../app.ts";
import Input, {TextArea} from "../components/input.tsx"; import Input, { TextArea } from "../components/input.tsx";
import TagInput from "../components/tag_input.tsx"; import TagInput from "../components/tag_input.tsx";
import Badge from "../components/badge.tsx"; import Badge from "../components/badge.tsx";
export default function TaggedResourcesPage() { export default function TaggedResourcesPage() {
const { tag: tagName } = useParams() const { tag: tagName } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -21,7 +21,7 @@ export default function TaggedResourcesPage() {
useEffect(() => { useEffect(() => {
document.title = t("Tag: ") + tagName; document.title = t("Tag: ") + tagName;
}, [t, tagName]) }, [t, tagName]);
useEffect(() => { useEffect(() => {
if (!tagName) { if (!tagName) {
@@ -35,44 +35,59 @@ export default function TaggedResourcesPage() {
}, [tagName]); }, [tagName]);
if (!tagName) { if (!tagName) {
return <div className={"m-4"}> return (
<div className={"m-4"}>
<ErrorAlert message={t("Tag not found")} /> <ErrorAlert message={t("Tag not found")} />
</div> </div>
);
} }
return <div> return (
<div>
<div className={"flex items-center"}> <div className={"flex items-center"}>
<h1 className={"text-2xl pt-6 pb-2 px-4 font-bold flex-1"}> <h1 className={"text-2xl pt-6 pb-2 px-4 font-bold flex-1"}>
{tag?.name ?? tagName} {tag?.name ?? tagName}
</h1> </h1>
{ {tag && app.canUpload() && (
(tag && app.canUpload()) && <EditTagButton tag={tag} onEdited={(t) => { <EditTagButton
setTag(t) tag={tag}
}} /> onEdited={(t) => {
} setTag(t);
}}
/>
)}
</div> </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"}> <div className={"px-3"}>
{ {(tag?.aliases ?? []).map((e) => {
(tag?.aliases ?? []).map((e) => { return <Badge className={"m-1 badge-primary badge-soft"}>{e}</Badge>;
return <Badge className={"m-1 badge-primary badge-soft"}>{e}</Badge> })}
})
}
</div> </div>
{ {tag?.description && (
tag?.description && <article className={"px-4 py-2"}> <article className={"px-4 py-2"}>
<Markdown> <Markdown>{tag.description}</Markdown>
{tag.description}
</Markdown>
</article> </article>
} )}
<ResourcesView loader={(page) => { <ResourcesView
return network.getResourcesByTag(tagName, page) loader={(page) => {
}}></ResourcesView> return network.getResourcesByTag(tagName, page);
}}
></ResourcesView>
</div> </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 [description, setDescription] = useState(tag.description);
const [isAlias, setIsAlias] = useState(false); const [isAlias, setIsAlias] = useState(false);
const [aliasOf, setAliasOf] = useState<Tag | null>(null); const [aliasOf, setAliasOf] = useState<Tag | null>(null);
@@ -82,7 +97,7 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
setDescription(tag.description) setDescription(tag.description);
}, [tag.description]); }, [tag.description]);
const submit = async () => { const submit = async () => {
@@ -92,10 +107,17 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }
} }
setIsLoading(true); setIsLoading(true);
setError(null); 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); setIsLoading(false);
if (res.success) { if (res.success) {
const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement; const dialog = document.getElementById(
"edit_tag_dialog",
) as HTMLDialogElement;
dialog.close(); dialog.close();
onEdited(res.data!); onEdited(res.data!);
} else { } else {
@@ -103,50 +125,87 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }
} }
}; };
return <> return (
<Button onClick={()=> { <>
const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement; <Button
onClick={() => {
const dialog = document.getElementById(
"edit_tag_dialog",
) as HTMLDialogElement;
dialog.showModal(); dialog.showModal();
}}>{t("Edit")}</Button> }}
>
{t("Edit")}
</Button>
<dialog id="edit_tag_dialog" className="modal"> <dialog id="edit_tag_dialog" className="modal">
<div className="modal-box" style={{ <div
overflowY: "initial" className="modal-box"
}}> style={{
overflowY: "initial",
}}
>
<h3 className="font-bold text-lg">{t("Edit Tag")}</h3> <h3 className="font-bold text-lg">{t("Edit Tag")}</h3>
<div className={"flex py-3"}> <div className={"flex py-3"}>
<p className={"flex-1"}>The tag is an alias of another tag</p> <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); setIsAlias(e.target.checked);
}}/> }}
/>
</div> </div>
{ {isAlias ? (
isAlias ? <> <>
{ {aliasOf && (
aliasOf && <div className={"py-2 border border-base-300 rounded-3xl mt-2 px-4 flex mb-4"}> <div
className={
"py-2 border border-base-300 rounded-3xl mt-2 px-4 flex mb-4"
}
>
<p className={"flex-1"}>Alias Of: </p> <p className={"flex-1"}>Alias Of: </p>
<Badge>{aliasOf.name}</Badge> <Badge>{aliasOf.name}</Badge>
</div> </div>
} )}
<TagInput mainTag={true} onAdd={(tag: Tag) => { <TagInput
mainTag={true}
onAdd={(tag: Tag) => {
setAliasOf(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} />} {error && <ErrorAlert className={"mt-2"} message={error} />}
<div className="modal-action"> <div className="modal-action">
<form method="dialog"> <form method="dialog">
<Button className="btn">{t("Close")}</Button> <Button className="btn">{t("Close")}</Button>
</form> </form>
<Button isLoading={isLoading} className={"btn-primary"} onClick={submit}> <Button
isLoading={isLoading}
className={"btn-primary"}
onClick={submit}
>
{t("Save")} {t("Save")}
</Button> </Button>
</div> </div>
</div> </div>
</dialog> </dialog>
</> </>
);
} }

View File

@@ -1,10 +1,10 @@
import {TagWithCount} from "../network/models.ts"; import { TagWithCount } from "../network/models.ts";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import {network} from "../network/network.ts"; import { network } from "../network/network.ts";
import showToast from "../components/toast.ts"; import showToast from "../components/toast.ts";
import Loading from "../components/loading.tsx"; import Loading from "../components/loading.tsx";
import Badge from "../components/badge.tsx"; import Badge from "../components/badge.tsx";
import {useNavigate} from "react-router"; import { useNavigate } from "react-router";
export default function TagsPage() { export default function TagsPage() {
const [tags, setTags] = useState<TagWithCount[] | null>(null); const [tags, setTags] = useState<TagWithCount[] | null>(null);
@@ -16,22 +16,22 @@ export default function TagsPage() {
} else { } else {
showToast({ showToast({
type: "error", type: "error",
message: res.message || "Failed to load tags" message: res.message || "Failed to load tags",
}) });
} }
}) });
}, []); }, []);
const navigate = useNavigate() const navigate = useNavigate();
if (!tags) { if (!tags) {
return <Loading/> return <Loading />;
} }
const tagsMap = new Map<string, TagWithCount[]>(); const tagsMap = new Map<string, TagWithCount[]>();
for (const tag of tags || []) { for (const tag of tags || []) {
const type = tag.type const type = tag.type;
if (!tagsMap.has(type)) { if (!tagsMap.has(type)) {
tagsMap.set(type, []); tagsMap.set(type, []);
} }
@@ -42,21 +42,30 @@ export default function TagsPage() {
tags.sort((a, b) => b.resources_count - a.resources_count); 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> <h1 className={"text-2xl font-bold py-2"}>Tags</h1>
{Array.from(tagsMap.entries()).map(([type, tags]) => ( {Array.from(tagsMap.entries()).map(([type, tags]) => (
<div key={type} className="flex flex-col gap-2"> <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> <p>
{tags.map(tag => ( {tags.map((tag) => (
<Badge onClick={() => { <Badge
onClick={() => {
navigate(`/tag/${tag.name}`); 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> </Badge>
))} ))}
</p> </p>
</div> </div>
))} ))}
</div> </div>
);
} }

View File

@@ -23,7 +23,7 @@ export default function UserPage() {
showToast({ showToast({
message: res.message, message: res.message,
type: "error", type: "error",
}) });
} }
}); });
}, [username]); }, [username]);
@@ -33,27 +33,44 @@ export default function UserPage() {
}, [username]); }, [username]);
if (!user) { if (!user) {
return <div className="w-full"> return (
<div className="w-full">
<Loading /> <Loading />
</div>; </div>
);
} }
return <div> return (
<div>
<UserCard user={user!} /> <UserCard user={user!} />
<div role="tablist" className="border-b border-base-300 mx-2 flex"> <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
<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> 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>
<div className="w-full"> <div className="w-full">
{page === 0 && <UserResources user={user} />} {page === 0 && <UserResources user={user} />}
{page === 1 && <UserComments user={user} />} {page === 1 && <UserComments user={user} />}
</div> </div>
<div className="h-16"></div> <div className="h-16"></div>
</div>; </div>
);
} }
function UserCard({ user }: { user: User }) { 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={"avatar py-2"}>
<div className="w-24 rounded-full ring-2 ring-offset-2 ring-primary ring-offset-base-100"> <div className="w-24 rounded-full ring-2 ring-offset-2 ring-primary ring-offset-base-100">
<img src={network.getUserAvatar(user)} /> <img src={network.getUserAvatar(user)} />
@@ -63,24 +80,36 @@ function UserCard({ user }: { user: User }) {
<div> <div>
<h1 className="text-2xl font-bold">{user.username}</h1> <h1 className="text-2xl font-bold">{user.username}</h1>
<div className="h-4"></div> <div className="h-4"></div>
{user.bio.trim() !== "" {user.bio.trim() !== "" ? (
? <p className="text-sm text-base-content/80">{user.bio.trim()}</p> <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> <p>
<span className="text-sm font-bold mr-1">
{" "}
{user.uploads_count}
</span>
<span className="text-sm">Resources</span> <span className="text-sm">Resources</span>
<span className="mx-2"></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> <span className="text-base-content text-sm">Comments</span>
</p> </p>
} )}
</div> </div>
</div> </div>
);
} }
function UserResources({ user }: { user: User }) { function UserResources({ user }: { user: User }) {
return <ResourcesView loader={(page) => { return (
<ResourcesView
loader={(page) => {
return network.getResourcesByUser(user.username, page); return network.getResourcesByUser(user.username, page);
}}></ResourcesView> }}
></ResourcesView>
);
} }
function UserComments({ user }: { user: User }) { function UserComments({ user }: { user: User }) {
@@ -88,18 +117,30 @@ function UserComments({ user }: { user: User }) {
const [maxPage, setMaxPage] = useState(0); const [maxPage, setMaxPage] = useState(0);
return <div className="px-2"> return (
<CommentsList username={user.username} page={page} maxPageCallback={setMaxPage} /> <div className="px-2">
{maxPage ? <div className={"w-full flex justify-center"}> <CommentsList
username={user.username}
page={page}
maxPageCallback={setMaxPage}
/>
{maxPage ? (
<div className={"w-full flex justify-center"}>
<Pagination page={page} setPage={setPage} totalPages={maxPage} /> <Pagination page={page} setPage={setPage} totalPages={maxPage} />
</div> : null}
</div> </div>
) : null}
</div>
);
} }
function CommentsList({ username, page, maxPageCallback }: { function CommentsList({
username: string, username,
page: number, page,
maxPageCallback: (maxPage: number) => void maxPageCallback,
}: {
username: string;
page: number;
maxPageCallback: (maxPage: number) => void;
}) { }) {
const [comments, setComments] = useState<CommentWithResource[] | null>(null); const [comments, setComments] = useState<CommentWithResource[] | null>(null);
@@ -118,24 +159,27 @@ function CommentsList({ username, page, maxPageCallback }: {
}, [maxPageCallback, page, username]); }, [maxPageCallback, page, username]);
if (comments == null) { if (comments == null) {
return <div className={"w-full"}> return (
<div className={"w-full"}>
<Loading /> <Loading />
</div> </div>
);
} }
return <> return (
{ <>
comments.map((comment) => { {comments.map((comment) => {
return <CommentTile comment={comment} key={comment.id} /> return <CommentTile comment={comment} key={comment.id} />;
}) })}
}
</> </>
);
} }
function CommentTile({ comment }: { comment: CommentWithResource }) { function CommentTile({ comment }: { comment: CommentWithResource }) {
const navigate = useNavigate(); 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={"flex flex-row items-center my-1 mx-1"}>
<div className="avatar"> <div className="avatar">
<div className="w-8 rounded-full"> <div className="w-8 rounded-full">
@@ -145,16 +189,20 @@ function CommentTile({ comment }: { comment: CommentWithResource }) {
<div className={"w-2"}></div> <div className={"w-2"}></div>
<div className={"text-sm font-bold"}>{comment.user.username}</div> <div className={"text-sm font-bold"}>{comment.user.username}</div>
<div className={"grow"}></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>
<div className={"p-2"}>
{comment.content}
</div> </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); navigate("/resources/" + comment.resource.id);
}}> }}
>
<MdOutlineArrowRight className="inline-block mr-1 mb-0.5" size={18} /> <MdOutlineArrowRight className="inline-block mr-1 mb-0.5" size={18} />
{comment.resource.title} {comment.resource.title}
</a> </a>
</div> </div>
);
} }

View File

@@ -1,16 +1,16 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
import tailwindcss from '@tailwindcss/vite' import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss(),], plugins: [react(), tailwindcss()],
server: { server: {
proxy: { proxy: {
'/api': { "/api": {
target: 'http://localhost:3000', target: 'http://localhost:3000',
changeOrigin: true, changeOrigin: true,
}, },
}, },
}, },
}) });