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: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
},
})
});
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
import reactX from "eslint-plugin-react-x";
import reactDom from "eslint-plugin-react-dom";
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
"react-x": reactX,
"react-dom": reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactX.configs["recommended-typescript"].rules,
...reactDom.configs.recommended.rules,
},
})
});
```

View File

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

View File

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

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

View File

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

View File

@@ -1,18 +1,53 @@
export function ErrorAlert({ message, className }: { message: string, className?: string }) {
return <div role="alert" className={`alert alert-error ${className}`}>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{message}</span>
</div>;
export function ErrorAlert({
message,
className,
}: {
message: string;
className?: string;
}) {
return (
<div role="alert" className={`alert alert-error ${className}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{message}</span>
</div>
);
}
export function InfoAlert({ message, className }: { message: string, className?: string }) {
return <div role="alert" className={`alert alert-info ${className}`}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-6 w-6 shrink-0 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>{message}</span>
</div>;
}
export function InfoAlert({
message,
className,
}: {
message: string;
className?: string;
}) {
return (
<div role="alert" className={`alert alert-info ${className}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>{message}</span>
</div>
);
}

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

View File

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

View File

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

View File

@@ -11,15 +11,31 @@ interface InputProps {
export default function Input(props: InputProps) {
if (props.inlineLabel) {
return <label className="input w-full">
{props.label}
<input type={props.type} className="grow" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
</label>
return (
<label className="input w-full">
{props.label}
<input
type={props.type}
className="grow"
placeholder={props.placeholder}
value={props.value}
onChange={props.onChange}
/>
</label>
);
} else {
return <fieldset className="fieldset w-full">
<legend className="fieldset-legend">{props.label}</legend>
<input type={props.type} className="input w-full" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
</fieldset>
return (
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{props.label}</legend>
<input
type={props.type}
className="input w-full"
placeholder={props.placeholder}
value={props.value}
onChange={props.onChange}
/>
</fieldset>
);
}
}
@@ -31,10 +47,17 @@ interface TextAreaProps {
}
export function TextArea(props: TextAreaProps) {
return <fieldset className="fieldset w-full">
<legend className="fieldset-legend">{props.label}</legend>
<textarea className={`textarea w-full ${props.height != undefined ? "resize-none" : ""}`} value={props.value} onChange={props.onChange} style={{
height: props.height,
}} />
</fieldset>
}
return (
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{props.label}</legend>
<textarea
className={`textarea w-full ${props.height != undefined ? "resize-none" : ""}`}
value={props.value}
onChange={props.onChange}
style={{
height: props.height,
}}
/>
</fieldset>
);
}

View File

@@ -1,10 +1,12 @@
import { useTranslation } from "react-i18next";
export default function Loading() {
const {t} = useTranslation();
const { t } = useTranslation();
return <div className={"flex justify-center py-4"}>
<span className="loading loading-spinner progress-primary loading-lg mr-2"></span>
<span>{t("Loading")}</span>
</div>;
}
return (
<div className={"flex justify-center py-4"}>
<span className="loading loading-spinner progress-primary loading-lg mr-2"></span>
<span>{t("Loading")}</span>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -4,48 +4,57 @@ import { useNavigate } from "react-router";
import Badge from "./badge.tsx";
export default function ResourceCard({ resource }: { resource: Resource }) {
const navigate = useNavigate()
const navigate = useNavigate();
let tags = resource.tags
let tags = resource.tags;
if (tags.length > 10) {
tags = tags.slice(0, 10)
tags = tags.slice(0, 10);
}
return <div className={"p-2 cursor-pointer"} onClick={() => {
navigate(`/resources/${resource.id}`)
}}>
<div className={"card shadow hover:shadow-md transition-shadow"}>
{
resource.image != null && <figure>
<img
src={network.getImageUrl(resource.image.id)}
alt="cover" style={{
width: "100%",
aspectRatio: resource.image.width / resource.image.height,
}}/>
</figure>
}
<div className="flex flex-col p-4">
<h2 className="card-title">{resource.title}</h2>
<div className="h-2"></div>
<p>
{
tags.map((tag) => {
return <Badge key={tag.id} className={"m-0.5"}>{tag.name}</Badge>
})
}
</p>
<div className="h-2"></div>
<div className="flex items-center">
<div className="avatar">
<div className="w-6 rounded-full">
<img src={network.getUserAvatar(resource.author)} />
return (
<div
className={"p-2 cursor-pointer"}
onClick={() => {
navigate(`/resources/${resource.id}`);
}}
>
<div className={"card shadow hover:shadow-md transition-shadow"}>
{resource.image != null && (
<figure>
<img
src={network.getImageUrl(resource.image.id)}
alt="cover"
style={{
width: "100%",
aspectRatio: resource.image.width / resource.image.height,
}}
/>
</figure>
)}
<div className="flex flex-col p-4">
<h2 className="card-title">{resource.title}</h2>
<div className="h-2"></div>
<p>
{tags.map((tag) => {
return (
<Badge key={tag.id} className={"m-0.5"}>
{tag.name}
</Badge>
);
})}
</p>
<div className="h-2"></div>
<div className="flex items-center">
<div className="avatar">
<div className="w-6 rounded-full">
<img src={network.getUserAvatar(resource.author)} />
</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{resource.author.username}</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{resource.author.username}</div>
</div>
</div>
</div>
</div>
}
);
}

View File

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

View File

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

View File

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

View File

@@ -7,41 +7,57 @@ export default function UploadingSideBar() {
useEffect(() => {
const listener = () => {
console.log("Uploading tasks changed; show uploading: ", uploadingManager.hasTasks());
setShowUploading(uploadingManager.hasTasks())
console.log(
"Uploading tasks changed; show uploading: ",
uploadingManager.hasTasks(),
);
setShowUploading(uploadingManager.hasTasks());
};
uploadingManager.addListener(listener)
uploadingManager.addListener(listener);
return () => {
uploadingManager.removeListener(listener)
}
uploadingManager.removeListener(listener);
};
}, []);
if (!showUploading) {
return <></>
return <></>;
}
return <>
<label htmlFor={"uploading-drawer"} className={"btn btn-square btn-ghost relative btn-accent text-primary"}>
<div className={"w-6 h-6 overflow-hidden relative"}>
<MdArrowUpward className={"move-up-animation pb-0.5"} size={24} />
<div className={"absolute border-b-2 w-5 bottom-1 left-0.5"}></div>
</div>
</label>
<div className="drawer w-0">
<input id="uploading-drawer" type="checkbox" className="drawer-toggle" />
<div className="drawer-side">
<label htmlFor="uploading-drawer" aria-label="close sidebar" className="drawer-overlay"></label>
<div className="menu bg-base-200 text-base-content h-full w-80 p-4 overflow-y-auto ">
<div className={"grid grid-cols-1"}>
<h2 className={"text-xl mb-2"}>Uploading</h2>
<UploadingList />
return (
<>
<label
htmlFor={"uploading-drawer"}
className={"btn btn-square btn-ghost relative btn-accent text-primary"}
>
<div className={"w-6 h-6 overflow-hidden relative"}>
<MdArrowUpward className={"move-up-animation pb-0.5"} size={24} />
<div className={"absolute border-b-2 w-5 bottom-1 left-0.5"}></div>
</div>
</label>
<div className="drawer w-0">
<input
id="uploading-drawer"
type="checkbox"
className="drawer-toggle"
/>
<div className="drawer-side">
<label
htmlFor="uploading-drawer"
aria-label="close sidebar"
className="drawer-overlay"
></label>
<div className="menu bg-base-200 text-base-content h-full w-80 p-4 overflow-y-auto ">
<div className={"grid grid-cols-1"}>
<h2 className={"text-xl mb-2"}>Uploading</h2>
<UploadingList />
</div>
</div>
</div>
</div>
</div>
</>
</>
);
}
function UploadingList() {
@@ -50,22 +66,22 @@ function UploadingList() {
useEffect(() => {
const listener = () => {
setTasks(uploadingManager.getTasks());
}
};
uploadingManager.addListener(listener)
uploadingManager.addListener(listener);
return () => {
uploadingManager.removeListener(listener)
}
uploadingManager.removeListener(listener);
};
}, []);
return <>
{
tasks.map((task) => {
return <TaskTile key={task.id} task={task} />
})
}
</>
return (
<>
{tasks.map((task) => {
return <TaskTile key={task.id} task={task} />;
})}
</>
);
}
function TaskTile({ task }: { task: UploadingTask }) {
@@ -77,51 +93,73 @@ function TaskTile({ task }: { task: UploadingTask }) {
const listener = () => {
setProgress(task.progress);
setError(task.errorMessage);
}
};
task.addListener(listener)
task.addListener(listener);
return () => {
task.removeListener(listener)
}
task.removeListener(listener);
};
}, [task]);
return <div className={"card card-border border-base-300 p-2 my-2 w-full"}>
<p className={"p-1 mb-2 w-full break-all line-clamp-2"}>{task.filename}</p>
<progress className="progress progress-primary my-2" value={100 * progress} max={100} />
{error && <p className={"text-error p-1"}>{error}</p>}
<div className={"my-2 flex flex-row-reverse"}>
{
error && <button className={"btn h-7 mr-1"} onClick={() => {
task.start();
}}>
Retry
</button>
}
<button className={"btn btn-error h-7"} onClick={() => {
const dialog = document.getElementById(`cancel_task_${task.id}`) as HTMLDialogElement;
dialog.showModal();
}}>
Cancel
</button>
</div>
<dialog id={`cancel_task_${task.id}`} className="modal">
<div className="modal-box">
<h3 className="text-lg font-bold">Cancel Task</h3>
<p className="py-4">Are you sure you want to cancel this task?</p>
<div className="modal-action">
<form method="dialog">
<button className="btn">Close</button>
</form>
<button className="btn btn-error mx-2" type={"button"} onClick={() => {
task.cancel();
const dialog = document.getElementById(`cancel_task_${task.id}`) as HTMLDialogElement;
dialog.close();
}}>
Confirm
return (
<div className={"card card-border border-base-300 p-2 my-2 w-full"}>
<p className={"p-1 mb-2 w-full break-all line-clamp-2"}>
{task.filename}
</p>
<progress
className="progress progress-primary my-2"
value={100 * progress}
max={100}
/>
{error && <p className={"text-error p-1"}>{error}</p>}
<div className={"my-2 flex flex-row-reverse"}>
{error && (
<button
className={"btn h-7 mr-1"}
onClick={() => {
task.start();
}}
>
Retry
</button>
</div>
)}
<button
className={"btn btn-error h-7"}
onClick={() => {
const dialog = document.getElementById(
`cancel_task_${task.id}`,
) as HTMLDialogElement;
dialog.showModal();
}}
>
Cancel
</button>
</div>
</dialog>
</div>
}
<dialog id={`cancel_task_${task.id}`} className="modal">
<div className="modal-box">
<h3 className="text-lg font-bold">Cancel Task</h3>
<p className="py-4">Are you sure you want to cancel this task?</p>
<div className="modal-action">
<form method="dialog">
<button className="btn">Close</button>
</form>
<button
className="btn btn-error mx-2"
type={"button"}
onClick={() => {
task.cancel();
const dialog = document.getElementById(
`cancel_task_${task.id}`,
) as HTMLDialogElement;
dialog.close();
}}
>
Confirm
</button>
</div>
</div>
</dialog>
</div>
);
}

View File

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

View File

@@ -2,131 +2,131 @@
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(99% 0.014 343.198);
--color-base-200: oklch(97% 0.028 342.258);
--color-base-300: oklch(84% 0.061 343.231);
--color-base-content: oklch(0% 0 0);
--color-primary: oklch(65% 0.241 354.308);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(62% 0.265 303.9);
--color-secondary-content: oklch(97% 0.014 308.299);
--color-accent: oklch(0.759 0.124 274.458);
--color-accent-content: oklch(20% 0.09 240.876);
--color-neutral: oklch(40% 0.153 2.432);
--color-neutral-content: oklch(89% 0.061 343.231);
--color-info: oklch(75% 0.105 251.813);
--color-info-content: oklch(44% 0.11 240.79);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(43% 0.095 166.913);
--color-warning: oklch(75% 0.183 55.934);
--color-warning-content: oklch(26% 0.079 36.259);
--color-error: oklch(63% 0.237 25.331);
--color-error-content: oklch(97% 0.013 17.38);
--radius-selector: 1rem;
--radius-field: 1.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(99% 0.014 343.198);
--color-base-200: oklch(97% 0.028 342.258);
--color-base-300: oklch(84% 0.061 343.231);
--color-base-content: oklch(0% 0 0);
--color-primary: oklch(65% 0.241 354.308);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(62% 0.265 303.9);
--color-secondary-content: oklch(97% 0.014 308.299);
--color-accent: oklch(0.759 0.124 274.458);
--color-accent-content: oklch(20% 0.09 240.876);
--color-neutral: oklch(40% 0.153 2.432);
--color-neutral-content: oklch(89% 0.061 343.231);
--color-info: oklch(75% 0.105 251.813);
--color-info-content: oklch(44% 0.11 240.79);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(43% 0.095 166.913);
--color-warning: oklch(75% 0.183 55.934);
--color-warning-content: oklch(26% 0.079 36.259);
--color-error: oklch(63% 0.237 25.331);
--color-error-content: oklch(97% 0.013 17.38);
--radius-selector: 1rem;
--radius-field: 1.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(28% 0.022 277.508);
--color-base-200: oklch(24% 0.02 277.508);
--color-base-300: oklch(40% 0.153 2.432);
--color-base-content: oklch(97.747% 0.007 106.545);
--color-primary: oklch(75.461% 0.183 346.812);
--color-primary-content: oklch(15.092% 0.036 346.812);
--color-secondary: oklch(74.202% 0.148 301.883);
--color-secondary-content: oklch(14.84% 0.029 301.883);
--color-accent: oklch(83.392% 0.124 66.558);
--color-accent-content: oklch(16.678% 0.024 66.558);
--color-neutral: oklch(39.445% 0.032 275.524);
--color-neutral-content: oklch(87.889% 0.006 275.524);
--color-info: oklch(88.263% 0.093 212.846);
--color-info-content: oklch(17.652% 0.018 212.846);
--color-success: oklch(87.099% 0.219 148.024);
--color-success-content: oklch(17.419% 0.043 148.024);
--color-warning: oklch(95.533% 0.134 112.757);
--color-warning-content: oklch(19.106% 0.026 112.757);
--color-error: oklch(68.22% 0.206 24.43);
--color-error-content: oklch(13.644% 0.041 24.43);
--radius-selector: 1rem;
--radius-field: 1.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(28% 0.022 277.508);
--color-base-200: oklch(24% 0.02 277.508);
--color-base-300: oklch(40% 0.153 2.432);
--color-base-content: oklch(97.747% 0.007 106.545);
--color-primary: oklch(75.461% 0.183 346.812);
--color-primary-content: oklch(15.092% 0.036 346.812);
--color-secondary: oklch(74.202% 0.148 301.883);
--color-secondary-content: oklch(14.84% 0.029 301.883);
--color-accent: oklch(83.392% 0.124 66.558);
--color-accent-content: oklch(16.678% 0.024 66.558);
--color-neutral: oklch(39.445% 0.032 275.524);
--color-neutral-content: oklch(87.889% 0.006 275.524);
--color-info: oklch(88.263% 0.093 212.846);
--color-info-content: oklch(17.652% 0.018 212.846);
--color-success: oklch(87.099% 0.219 148.024);
--color-success-content: oklch(17.419% 0.043 148.024);
--color-warning: oklch(95.533% 0.134 112.757);
--color-warning-content: oklch(19.106% 0.026 112.757);
--color-error: oklch(68.22% 0.206 24.43);
--color-error-content: oklch(13.644% 0.041 24.43);
--radius-selector: 1rem;
--radius-field: 1.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
html {
width: 100%;
height: 100%;
overflow-y: scroll;
width: 100%;
height: 100%;
overflow-y: scroll;
}
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
@keyframes appearance-in {
0% {
opacity: 0;
transform: translateZ(0) scale(0.95);
}
60% {
opacity: 0.75;
backface-visibility: hidden;
webkit-font-smoothing: antialiased;
transform: translateZ(0) scale(1.05);
}
100% {
opacity: 1;
transform: translateZ(0) scale(1);
}
0% {
opacity: 0;
transform: translateZ(0) scale(0.95);
}
60% {
opacity: 0.75;
backface-visibility: hidden;
webkit-font-smoothing: antialiased;
transform: translateZ(0) scale(1.05);
}
100% {
opacity: 1;
transform: translateZ(0) scale(1);
}
}
.animate-appearance-in {
animation: appearance-in 250ms ease-out normal both;
animation: appearance-in 250ms ease-out normal both;
}
.move-up-animation {
animation: moveUpAndDown 2s infinite;
position: relative;
animation: moveUpAndDown 2s infinite;
position: relative;
}
@keyframes moveUpAndDown {
0% {
top: 0;
}
100% {
top: -100%;
}
0% {
top: 0;
}
100% {
top: -100%;
}
}
.text-md {
font-size: 1rem;
line-height: 1.5rem;
}
font-size: 1rem;
line-height: 1.5rem;
}

View File

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

View File

@@ -1,110 +1,111 @@
article {
& {
color: var(--color-base-content);
}
& {
color: var(--color-base-content);
}
h1 {
font-size: 24px;
font-weight: bold;
padding: 12px 0;
margin: 24px 0 12px;
h1 {
font-size: 24px;
font-weight: bold;
padding: 12px 0;
margin: 24px 0 12px;
}
h2 {
font-size: 20px;
font-weight: bold;
padding: 12px 0;
margin: 16px 0 8px;
}
h3 {
font-size: 16px;
font-weight: bold;
padding: 6px 0;
margin: 12px 0 4px;
}
h4 {
font-size: 14px;
font-weight: bold;
padding: 6px 0;
margin: 12px 0 4px;
}
h5 {
font-size: 12px;
font-weight: bold;
padding: 4px 0;
}
h6 {
font-size: 10px;
font-weight: bold;
padding: 2px 0;
}
p {
font-size: 14px;
line-height: 1.6;
margin: 12px 0;
}
ul {
list-style-type: disc;
margin: 0 0 16px 20px;
padding: 0;
li {
font-size: 14px;
line-height: 1.5;
margin: 0 0 8px;
}
h2 {
font-size: 20px;
font-weight: bold;
padding: 12px 0;
margin: 16px 0 8px;
}
ol {
list-style-type: decimal;
margin: 0 0 16px 20px;
padding: 0;
li {
font-size: 14px;
line-height: 1.5;
margin: 0 0 8px;
}
h3 {
font-size: 16px;
font-weight: bold;
padding: 6px 0;
margin: 12px 0 4px;
}
h4 {
font-size: 14px;
font-weight: bold;
padding: 6px 0;
margin: 12px 0 4px;
}
h5 {
font-size: 12px;
font-weight: bold;
padding: 4px 0;
}
h6 {
font-size: 10px;
font-weight: bold;
padding: 2px 0;
}
p {
font-size: 14px;
line-height: 1.6;
margin: 12px 0;
}
ul {
list-style-type: disc;
margin: 0 0 16px 20px;
padding: 0;
li {
font-size: 14px;
line-height: 1.5;
margin: 0 0 8px;
}
}
ol {
list-style-type: decimal;
margin: 0 0 16px 20px;
padding: 0;
li {
font-size: 14px;
line-height: 1.5;
margin: 0 0 8px;
}
}
blockquote {
font-size: 14px;
line-height: 1.5;
margin: 0 0 16px;
padding: 8px;
border-left: 4px solid var(--color-base-300);
background-color: var(--color-base-200);
}
hr {
border: 0;
border-top: 1px solid var(--color-base-300);
margin: 16px 0;
}
a {
color: var(--color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
img {
border-radius: 8px;
max-height: 400px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
p:has(> img) {
margin: 16px 0;
}
p code {
background-color: var(--color-base-200);
padding: 2px 4px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, system-ui;
}
iframe{
width: 100%;
height: 100%;
}
blockquote {
font-size: 14px;
line-height: 1.5;
margin: 0 0 16px;
padding: 8px;
border-left: 4px solid var(--color-base-300);
background-color: var(--color-base-200);
}
hr {
border: 0;
border-top: 1px solid var(--color-base-300);
margin: 16px 0;
}
a {
color: var(--color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
img {
border-radius: 8px;
max-height: 400px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
p:has(> img) {
margin: 16px 0;
}
p code {
background-color: var(--color-base-200);
padding: 2px 4px;
border-radius: 4px;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, system-ui;
}
iframe {
width: 100%;
height: 100%;
}
}
a.no-underline {
text-decoration: none;
&:hover {
text-decoration: none;
&:hover {
text-decoration: none;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,26 @@
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import ResourcesView from "../components/resources_view.tsx";
import {network} from "../network/network.ts";
import { network } from "../network/network.ts";
import { app } from "../app.ts";
import {RSort} from "../network/models.ts";
import {useTranslation} from "react-i18next";
import {useAppContext} from "../components/AppContext.tsx";
import { RSort } from "../network/models.ts";
import { useTranslation } from "react-i18next";
import { useAppContext } from "../components/AppContext.tsx";
export default function HomePage() {
useEffect(() => {
document.title = app.appName;
}, [])
}, []);
const {t} = useTranslation()
const appContext = useAppContext()
const { t } = useTranslation();
const appContext = useAppContext();
const [order, setOrder] = useState(() => {
if (appContext && appContext.get("home_page_order") !== undefined) {
return appContext.get("home_page_order");
}
return RSort.TimeDesc;
})
});
useEffect(() => {
if (appContext && order !== RSort.TimeDesc) {
@@ -28,37 +28,43 @@ export default function HomePage() {
}
}, [appContext, order]);
return <>
<div className={"flex p-4 items-center"}>
<select value={order} className="select w-52 select-info" onInput={(e) => {
const value = e.currentTarget.value;
if (value === "0") {
setOrder(RSort.TimeAsc);
} else if (value === "1") {
setOrder(RSort.TimeDesc);
} else if (value === "2") {
setOrder(RSort.ViewsAsc);
} else if (value === "3") {
setOrder(RSort.ViewsDesc);
} else if (value === "4") {
setOrder(RSort.DownloadsAsc);
} else if (value === "5") {
setOrder(RSort.DownloadsDesc);
}
}}>
<option disabled>{t("Select a Order")}</option>
<option value="0">{t("Time Ascending")}</option>
<option value="1">{t("Time Descending")}</option>
<option value="2">{t("Views Ascending")}</option>
<option value="3">{t("Views Descending")}</option>
<option value="4">{t("Downloads Ascending")}</option>
<option value="5">{t("Downloads Descending")}</option>
</select>
</div>
<ResourcesView
key={`home_page_${order}`}
storageKey={`home_page_${order}`}
loader={(page) => network.getResources(page, order)}
/>
</>
}
return (
<>
<div className={"flex p-4 items-center"}>
<select
value={order}
className="select w-52 select-info"
onInput={(e) => {
const value = e.currentTarget.value;
if (value === "0") {
setOrder(RSort.TimeAsc);
} else if (value === "1") {
setOrder(RSort.TimeDesc);
} else if (value === "2") {
setOrder(RSort.ViewsAsc);
} else if (value === "3") {
setOrder(RSort.ViewsDesc);
} else if (value === "4") {
setOrder(RSort.DownloadsAsc);
} else if (value === "5") {
setOrder(RSort.DownloadsDesc);
}
}}
>
<option disabled>{t("Select a Order")}</option>
<option value="0">{t("Time Ascending")}</option>
<option value="1">{t("Time Descending")}</option>
<option value="2">{t("Views Ascending")}</option>
<option value="3">{t("Views Descending")}</option>
<option value="4">{t("Downloads Ascending")}</option>
<option value="5">{t("Downloads Descending")}</option>
</select>
</div>
<ResourcesView
key={`home_page_${order}`}
storageKey={`home_page_${order}`}
loader={(page) => network.getResources(page, order)}
/>
</>
);
}

View File

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

View File

@@ -3,7 +3,11 @@ import { app } from "../app";
import { ErrorAlert } from "../components/alert";
import { network } from "../network/network";
import { ReactNode, useState } from "react";
import { MdOutlineAccountCircle, MdLockOutline, MdOutlineEditNote } from "react-icons/md";
import {
MdOutlineAccountCircle,
MdLockOutline,
MdOutlineEditNote,
} from "react-icons/md";
import Button from "../components/button";
import showToast from "../components/toast";
import { useNavigator } from "../components/navigator";
@@ -13,26 +17,44 @@ export function ManageMePage() {
const { t } = useTranslation();
if (!app.user) {
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
return (
<ErrorAlert
className={"m-4"}
message={t("You are not logged in. Please log in to access this page.")}
/>
);
}
return <div className="px-2">
<ChangeAvatarDialog />
<ChangeUsernameDialog />
<ChangePasswordDialog />
<ChangeBioDialog />
</div>;
return (
<div className="px-2">
<ChangeAvatarDialog />
<ChangeUsernameDialog />
<ChangePasswordDialog />
<ChangeBioDialog />
</div>
);
}
function ListTile({ title, icon, onClick }: { title: string, icon: ReactNode, onClick: () => void }) {
return <div className="flex flex-row items-center h-12 px-2 bg-base-100 hover:bg-gray-200 cursor-pointer duration-200" onClick={onClick}>
<div className="flex flex-row items-center">
<span className="text-2xl">
{icon}
</span>
<span className="ml-2">{title}</span>
function ListTile({
title,
icon,
onClick,
}: {
title: string;
icon: ReactNode;
onClick: () => void;
}) {
return (
<div
className="flex flex-row items-center h-12 px-2 bg-base-100 hover:bg-gray-200 cursor-pointer duration-200"
onClick={onClick}
>
<div className="flex flex-row items-center">
<span className="text-2xl">{icon}</span>
<span className="ml-2">{title}</span>
</div>
</div>
</div>
);
}
function ChangeAvatarDialog() {
@@ -73,41 +95,68 @@ function ChangeAvatarDialog() {
showToast({
message: t("Avatar changed successfully"),
type: "success",
})
const dialog = document.getElementById("change_avatar_dialog") as HTMLDialogElement;
});
const dialog = document.getElementById(
"change_avatar_dialog",
) as HTMLDialogElement;
if (dialog) {
dialog.close();
}
}
}
};
return <>
<ListTile icon={<MdOutlineAccountCircle />} title={t("Change Avatar")} onClick={() => {
const dialog = document.getElementById("change_avatar_dialog") as HTMLDialogElement;
if (dialog) {
dialog.showModal();
}
}} />
<dialog id="change_avatar_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Change Avatar")}</h3>
<div className="h-48 flex items-center justify-center">
<div className="avatar">
<div className="w-28 rounded-full cursor-pointer" onClick={selectAvatar}>
<img src={avatar ? URL.createObjectURL(avatar) : network.getUserAvatar(app.user!)} alt={"avatar"} />
return (
<>
<ListTile
icon={<MdOutlineAccountCircle />}
title={t("Change Avatar")}
onClick={() => {
const dialog = document.getElementById(
"change_avatar_dialog",
) as HTMLDialogElement;
if (dialog) {
dialog.showModal();
}
}}
/>
<dialog id="change_avatar_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Change Avatar")}</h3>
<div className="h-48 flex items-center justify-center">
<div className="avatar">
<div
className="w-28 rounded-full cursor-pointer"
onClick={selectAvatar}
>
<img
src={
avatar
? URL.createObjectURL(avatar)
: network.getUserAvatar(app.user!)
}
alt={"avatar"}
/>
</div>
</div>
</div>
{error && <ErrorAlert message={error} className={"m-4"} />}
<div className="modal-action">
<form method="dialog">
<Button>{t("Close")}</Button>
</form>
<Button
className="btn-primary"
onClick={handleSubmit}
isLoading={isLoading}
disabled={avatar == null}
>
{t("Save")}
</Button>
</div>
</div>
{error && <ErrorAlert message={error} className={"m-4"} />}
<div className="modal-action">
<form method="dialog">
<Button>{t("Close")}</Button>
</form>
<Button className="btn-primary" onClick={handleSubmit} isLoading={isLoading} disabled={avatar == null}>{t("Save")}</Button>
</div>
</div>
</dialog>
</>
</dialog>
</>
);
}
function ChangeUsernameDialog() {
@@ -135,7 +184,9 @@ function ChangeUsernameDialog() {
message: t("Username changed successfully"),
type: "success",
});
const dialog = document.getElementById("change_username_dialog") as HTMLDialogElement;
const dialog = document.getElementById(
"change_username_dialog",
) as HTMLDialogElement;
if (dialog) {
dialog.close();
}
@@ -144,44 +195,50 @@ function ChangeUsernameDialog() {
}
};
return <>
<ListTile icon={<MdOutlineEditNote />} title={t("Change Username")} onClick={() => {
const dialog = document.getElementById("change_username_dialog") as HTMLDialogElement;
if (dialog) {
dialog.showModal();
}
}} />
<dialog id="change_username_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Change Username")}</h3>
<div className="input mt-4 w-full">
<label className="label">
{t("New Username")}
</label>
<input
type="text"
placeholder={t("Enter new username")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
/>
return (
<>
<ListTile
icon={<MdOutlineEditNote />}
title={t("Change Username")}
onClick={() => {
const dialog = document.getElementById(
"change_username_dialog",
) as HTMLDialogElement;
if (dialog) {
dialog.showModal();
}
}}
/>
<dialog id="change_username_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Change Username")}</h3>
<div className="input mt-4 w-full">
<label className="label">{t("New Username")}</label>
<input
type="text"
placeholder={t("Enter new username")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
/>
</div>
{error && <ErrorAlert message={error} className={"mt-4"} />}
<div className="modal-action">
<form method="dialog">
<Button>{t("Close")}</Button>
</form>
<Button
className="btn-primary"
onClick={handleSubmit}
isLoading={isLoading}
disabled={!newUsername.trim()}
>
{t("Save")}
</Button>
</div>
</div>
{error && <ErrorAlert message={error} className={"mt-4"} />}
<div className="modal-action">
<form method="dialog">
<Button>{t("Close")}</Button>
</form>
<Button
className="btn-primary"
onClick={handleSubmit}
isLoading={isLoading}
disabled={!newUsername.trim()}
>
{t("Save")}
</Button>
</div>
</div>
</dialog>
</>;
</dialog>
</>
);
}
function ChangePasswordDialog() {
@@ -220,17 +277,19 @@ function ChangePasswordDialog() {
// Update the token as it might have changed
app.token = res.data!.token;
app.user = res.data!;
showToast({
message: t("Password changed successfully"),
type: "success",
});
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement;
const dialog = document.getElementById(
"change_password_dialog",
) as HTMLDialogElement;
if (dialog) {
dialog.close();
}
// Reset form
setOldPassword("");
setNewPassword("");
@@ -239,68 +298,78 @@ function ChangePasswordDialog() {
}
};
return <>
<ListTile icon={<MdLockOutline />} title={t("Change Password")} onClick={() => {
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement;
if (dialog) {
dialog.showModal();
}
}} />
<dialog id="change_password_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg mb-2">{t("Change Password")}</h3>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Current Password")}</legend>
<input
type="password"
placeholder={t("Enter current password")}
value={oldPassword}
className="input w-full"
onChange={(e) => setOldPassword(e.target.value)}
/>
</fieldset>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("New Password")}</legend>
<input
type="password"
placeholder={t("Enter new password")}
value={newPassword}
className="input w-full"
onChange={(e) => setNewPassword(e.target.value)}
/>
</fieldset>
return (
<>
<ListTile
icon={<MdLockOutline />}
title={t("Change Password")}
onClick={() => {
const dialog = document.getElementById(
"change_password_dialog",
) as HTMLDialogElement;
if (dialog) {
dialog.showModal();
}
}}
/>
<dialog id="change_password_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg mb-2">{t("Change Password")}</h3>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Confirm New Password")}</legend>
<input
type="password"
placeholder={t("Confirm new password")}
value={confirmPassword}
className="input w-full"
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</fieldset>
{error && <ErrorAlert message={error} className={"mt-4"} />}
<div className="modal-action">
<form method="dialog">
<Button>{t("Close")}</Button>
</form>
<Button
className="btn-primary"
onClick={handleSubmit}
isLoading={isLoading}
disabled={!oldPassword || !newPassword || !confirmPassword}
>
{t("Save")}
</Button>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Current Password")}</legend>
<input
type="password"
placeholder={t("Enter current password")}
value={oldPassword}
className="input w-full"
onChange={(e) => setOldPassword(e.target.value)}
/>
</fieldset>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("New Password")}</legend>
<input
type="password"
placeholder={t("Enter new password")}
value={newPassword}
className="input w-full"
onChange={(e) => setNewPassword(e.target.value)}
/>
</fieldset>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">
{t("Confirm New Password")}
</legend>
<input
type="password"
placeholder={t("Confirm new password")}
value={confirmPassword}
className="input w-full"
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</fieldset>
{error && <ErrorAlert message={error} className={"mt-4"} />}
<div className="modal-action">
<form method="dialog">
<Button>{t("Close")}</Button>
</form>
<Button
className="btn-primary"
onClick={handleSubmit}
isLoading={isLoading}
disabled={!oldPassword || !newPassword || !confirmPassword}
>
{t("Save")}
</Button>
</div>
</div>
</div>
</dialog>
</>;
</dialog>
</>
);
}
function ChangeBioDialog() {
@@ -329,7 +398,9 @@ function ChangeBioDialog() {
message: t("Bio changed successfully"),
type: "success",
});
const dialog = document.getElementById("change_bio_dialog") as HTMLDialogElement;
const dialog = document.getElementById(
"change_bio_dialog",
) as HTMLDialogElement;
if (dialog) {
dialog.close();
}
@@ -338,32 +409,44 @@ function ChangeBioDialog() {
}
};
return <>
<ListTile icon={<MdOutlineEditNote />} title={t("Change Bio")} onClick={() => {
const dialog = document.getElementById("change_bio_dialog") as HTMLDialogElement;
if (dialog) {
dialog.showModal();
}
}} />
<dialog id="change_bio_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Change Bio")}</h3>
<Input value={bio} onChange={(e) => setBio(e.target.value)} label={"bio"} />
{error && <ErrorAlert message={error} className={"mt-4"} />}
<div className="modal-action">
<form method="dialog">
<Button>{t("Close")}</Button>
</form>
<Button
className="btn-primary"
onClick={handleSubmit}
isLoading={isLoading}
disabled={!bio.trim()}
>
{t("Save")}
</Button>
return (
<>
<ListTile
icon={<MdOutlineEditNote />}
title={t("Change Bio")}
onClick={() => {
const dialog = document.getElementById(
"change_bio_dialog",
) as HTMLDialogElement;
if (dialog) {
dialog.showModal();
}
}}
/>
<dialog id="change_bio_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Change Bio")}</h3>
<Input
value={bio}
onChange={(e) => setBio(e.target.value)}
label={"bio"}
/>
{error && <ErrorAlert message={error} className={"mt-4"} />}
<div className="modal-action">
<form method="dialog">
<Button>{t("Close")}</Button>
</form>
<Button
className="btn-primary"
onClick={handleSubmit}
isLoading={isLoading}
disabled={!bio.trim()}
>
{t("Save")}
</Button>
</div>
</div>
</div>
</dialog>
</>;
}
</dialog>
</>
);
}

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

View File

@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { app } from "../app"
import { ErrorAlert, InfoAlert } from "../components/alert"
import { app } from "../app";
import { ErrorAlert, InfoAlert } from "../components/alert";
import { useEffect, useState } from "react";
import { ServerConfig } from "../network/models";
import Loading from "../components/loading";
import Input, {TextArea} from "../components/input";
import Input, { TextArea } from "../components/input";
import { network } from "../network/network";
import showToast from "../components/toast";
import Button from "../components/button";
@@ -24,21 +24,31 @@ export default function ManageServerConfigPage() {
showToast({
message: res.message,
type: "error",
})
});
}
})
});
}, []);
if (!app.user) {
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
return (
<ErrorAlert
className={"m-4"}
message={t("You are not logged in. Please log in to access this page.")}
/>
);
}
if (!app.user?.is_admin) {
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
return (
<ErrorAlert
className={"m-4"}
message={t("You are not authorized to access this page.")}
/>
);
}
if (config == null) {
return <Loading />
return <Loading />;
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -62,40 +72,107 @@ export default function ManageServerConfigPage() {
setIsLoading(false);
};
return <form className="px-4 pb-4" onSubmit={handleSubmit}>
<Input type="number" value={config.max_uploading_size_in_mb.toString()} label="Max uploading size (MB)" onChange={(e) => {
setConfig({...config, max_uploading_size_in_mb: parseInt(e.target.value) })
}}></Input>
<Input type="number" value={config.max_file_size_in_mb.toString()} label="Max file size (MB)" onChange={(e) => {
setConfig({...config, max_file_size_in_mb: parseInt(e.target.value) })
}}></Input>
<Input type="number" value={config.max_downloads_per_day_for_single_ip.toString()} label="Max downloads per day for single IP" onChange={(e) => {
setConfig({...config, max_downloads_per_day_for_single_ip: parseInt(e.target.value) })
}}></Input>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">Allow register</legend>
<input type="checkbox" checked={config.allow_register} className="toggle-primary toggle" onChange={(e) => {
setConfig({ ...config, allow_register: e.target.checked })
}} />
</fieldset>
<Input type="text" value={config.server_name} label="Server name" onChange={(e) => {
setConfig({...config, server_name: e.target.value })
}}></Input>
<Input type="text" value={config.server_description} label="Server description" onChange={(e) => {
setConfig({...config, server_description: e.target.value })
}}></Input>
<Input type="text" value={config.cloudflare_turnstile_site_key} label="Cloudflare Turnstile Site Key" onChange={(e) => {
setConfig({...config, cloudflare_turnstile_site_key: e.target.value })
}}></Input>
<Input type="text" value={config.cloudflare_turnstile_secret_key} label="Cloudflare Turnstile Secret Key" onChange={(e) => {
setConfig({...config, cloudflare_turnstile_secret_key: e.target.value })
}}></Input>
<TextArea value={config.site_info} onChange={(e) => {
setConfig({...config, site_info: e.target.value })
}} label="Site info (Markdown)" height={180} />
<InfoAlert className="my-2" message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download." />
<div className="flex justify-end">
<Button className="btn-accent shadow" isLoading={isLoading}>{t("Submit")}</Button>
</div>
</form>
}
return (
<form className="px-4 pb-4" onSubmit={handleSubmit}>
<Input
type="number"
value={config.max_uploading_size_in_mb.toString()}
label="Max uploading size (MB)"
onChange={(e) => {
setConfig({
...config,
max_uploading_size_in_mb: parseInt(e.target.value),
});
}}
></Input>
<Input
type="number"
value={config.max_file_size_in_mb.toString()}
label="Max file size (MB)"
onChange={(e) => {
setConfig({
...config,
max_file_size_in_mb: parseInt(e.target.value),
});
}}
></Input>
<Input
type="number"
value={config.max_downloads_per_day_for_single_ip.toString()}
label="Max downloads per day for single IP"
onChange={(e) => {
setConfig({
...config,
max_downloads_per_day_for_single_ip: parseInt(e.target.value),
});
}}
></Input>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">Allow register</legend>
<input
type="checkbox"
checked={config.allow_register}
className="toggle-primary toggle"
onChange={(e) => {
setConfig({ ...config, allow_register: e.target.checked });
}}
/>
</fieldset>
<Input
type="text"
value={config.server_name}
label="Server name"
onChange={(e) => {
setConfig({ ...config, server_name: e.target.value });
}}
></Input>
<Input
type="text"
value={config.server_description}
label="Server description"
onChange={(e) => {
setConfig({ ...config, server_description: e.target.value });
}}
></Input>
<Input
type="text"
value={config.cloudflare_turnstile_site_key}
label="Cloudflare Turnstile Site Key"
onChange={(e) => {
setConfig({
...config,
cloudflare_turnstile_site_key: e.target.value,
});
}}
></Input>
<Input
type="text"
value={config.cloudflare_turnstile_secret_key}
label="Cloudflare Turnstile Secret Key"
onChange={(e) => {
setConfig({
...config,
cloudflare_turnstile_secret_key: e.target.value,
});
}}
></Input>
<TextArea
value={config.site_info}
onChange={(e) => {
setConfig({ ...config, site_info: e.target.value });
}}
label="Site info (Markdown)"
height={180}
/>
<InfoAlert
className="my-2"
message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download."
/>
<div className="flex justify-end">
<Button className="btn-accent shadow" isLoading={isLoading}>
{t("Submit")}
</Button>
</div>
</form>
);
}

View File

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

View File

@@ -19,35 +19,72 @@ export default function UserView() {
const [totalPages, setTotalPages] = useState(0);
if (!app.user) {
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
return (
<ErrorAlert
className={"m-4"}
message={t("You are not logged in. Please log in to access this page.")}
/>
);
}
if (!app.user?.is_admin) {
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
return (
<ErrorAlert
className={"m-4"}
message={t("You are not authorized to access this page.")}
/>
);
}
return <>
<div className={"flex flex-row justify-between items-center mx-4 my-4"}>
<form className={"flex flex-row gap-2 items-center w-64"} onSubmit={(e) => {
e.preventDefault();
setPage(0);
const input = e.currentTarget.querySelector("input[type=search]") as HTMLInputElement;
setSearchKeyword(input.value);
}}>
<label className="input">
<MdSearch size={20} className="opacity-50" />
<input type="search" className="grow" placeholder={t("Search")} id="search" />
</label>
</form>
</div>
<UserTable page={page} searchKeyword={searchKeyword} key={`${page}&${searchKeyword}`} totalPagesCallback={setTotalPages} />
<div className={"flex flex-row justify-center items-center my-4"}>
{totalPages ? <Pagination page={page} setPage={setPage} totalPages={totalPages} /> : null}
</div>
</>
return (
<>
<div className={"flex flex-row justify-between items-center mx-4 my-4"}>
<form
className={"flex flex-row gap-2 items-center w-64"}
onSubmit={(e) => {
e.preventDefault();
setPage(0);
const input = e.currentTarget.querySelector(
"input[type=search]",
) as HTMLInputElement;
setSearchKeyword(input.value);
}}
>
<label className="input">
<MdSearch size={20} className="opacity-50" />
<input
type="search"
className="grow"
placeholder={t("Search")}
id="search"
/>
</label>
</form>
</div>
<UserTable
page={page}
searchKeyword={searchKeyword}
key={`${page}&${searchKeyword}`}
totalPagesCallback={setTotalPages}
/>
<div className={"flex flex-row justify-center items-center my-4"}>
{totalPages ? (
<Pagination page={page} setPage={setPage} totalPages={totalPages} />
) : null}
</div>
</>
);
}
function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number, searchKeyword: string, totalPagesCallback: (totalPages: number) => void }) {
function UserTable({
page,
searchKeyword,
totalPagesCallback,
}: {
page: number;
searchKeyword: string;
totalPagesCallback: (totalPages: number) => void;
}) {
const { t } = useTranslation();
const [users, setUsers] = useState<User[] | null>(null);
@@ -61,7 +98,7 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
showToast({
type: "error",
message: response.message,
})
});
}
});
} else {
@@ -73,7 +110,7 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
showToast({
type: "error",
message: response.message,
})
});
}
});
}
@@ -92,29 +129,31 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
return <Loading />;
}
return <div className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto`}>
<table className={"table"}>
<thead>
<tr>
<td>{t("Username")}</td>
<td>{t("Created At")}</td>
<td>{t("Admin")}</td>
<td>{t("Can Upload")}</td>
<td>{t("Actions")}</td>
</tr>
</thead>
<tbody>
{
users.map((u) => {
return <UserRow key={u.id} user={u} onChanged={handleChanged} />
})
}
</tbody>
</table>
</div>
return (
<div
className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto`}
>
<table className={"table"}>
<thead>
<tr>
<td>{t("Username")}</td>
<td>{t("Created At")}</td>
<td>{t("Admin")}</td>
<td>{t("Can Upload")}</td>
<td>{t("Actions")}</td>
</tr>
</thead>
<tbody>
{users.map((u) => {
return <UserRow key={u.id} user={u} onChanged={handleChanged} />;
})}
</tbody>
</table>
</div>
);
}
function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
function UserRow({ user, onChanged }: { user: User; onChanged: () => void }) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
@@ -139,7 +178,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
});
}
setIsLoading(false);
}
};
const handleSetAdmin = async () => {
if (isLoading) {
@@ -160,7 +199,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
});
}
setIsLoading(false);
}
};
const handleSetUser = async () => {
if (isLoading) {
@@ -181,7 +220,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
});
}
setIsLoading(false);
}
};
const handleSetUploadPermission = async () => {
if (isLoading) {
@@ -202,7 +241,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
});
}
setIsLoading(false);
}
};
const handleRemoveUploadPermission = async () => {
if (isLoading) {
@@ -223,55 +262,86 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
});
}
setIsLoading(false);
}
};
return <tr key={user.id} className={"hover"}>
<td>
{user.username}
</td>
<td>
{(new Date(user.created_at)).toLocaleDateString()}
</td>
<td>
{user.is_admin ? t("Yes") : t("No")}
</td>
<td>
{user.can_upload ? t("Yes") : t("No")}
</td>
<td>
<div className="dropdown dropdown-bottom dropdown-end">
<button ref={buttonRef} className="btn btn-square m-1" onClick={() => {
showPopup(<ul className="menu bg-base-100 rounded-box z-1 w-64 p-2 shadow-sm">
<h4 className="text-sm font-bold px-3 py-1 text-primary">{t("Actions")}</h4>
<PopupMenuItem onClick={() => {
const dialog = document.getElementById(`delete_user_dialog_${user.id}`) as HTMLDialogElement;
dialog.showModal();
}}>
<a>{t("Delete")}</a>
</PopupMenuItem>
{user.is_admin ? <PopupMenuItem onClick={handleSetUser}><a>{t("Set as user")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetAdmin}><a>{t("Set as admin")}</a></PopupMenuItem>}
{app.user?.is_admin ? (
user.can_upload ? <PopupMenuItem onClick={handleRemoveUploadPermission}><a>{t("Remove upload permission")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetUploadPermission}><a>{t("Grant upload permission")}</a></PopupMenuItem>
) : null}
</ul>, buttonRef.current!);
}}>
{isLoading
? <span className="loading loading-spinner loading-sm"></span>
: <MdMoreHoriz size={20} className="opacity-50" />}
</button>
<dialog id={`delete_user_dialog_${user.id}`} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Delete User")}</h3>
<p className="py-4">{t("Are you sure you want to delete user")} <span className="font-bold">{user.username}</span>? {t("This action cannot be undone.")}</p>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
<button className="btn btn-error" onClick={handleDelete}>{t("Delete")}</button>
</form>
return (
<tr key={user.id} className={"hover"}>
<td>{user.username}</td>
<td>{new Date(user.created_at).toLocaleDateString()}</td>
<td>{user.is_admin ? t("Yes") : t("No")}</td>
<td>{user.can_upload ? t("Yes") : t("No")}</td>
<td>
<div className="dropdown dropdown-bottom dropdown-end">
<button
ref={buttonRef}
className="btn btn-square m-1"
onClick={() => {
showPopup(
<ul className="menu bg-base-100 rounded-box z-1 w-64 p-2 shadow-sm">
<h4 className="text-sm font-bold px-3 py-1 text-primary">
{t("Actions")}
</h4>
<PopupMenuItem
onClick={() => {
const dialog = document.getElementById(
`delete_user_dialog_${user.id}`,
) as HTMLDialogElement;
dialog.showModal();
}}
>
<a>{t("Delete")}</a>
</PopupMenuItem>
{user.is_admin ? (
<PopupMenuItem onClick={handleSetUser}>
<a>{t("Set as user")}</a>
</PopupMenuItem>
) : (
<PopupMenuItem onClick={handleSetAdmin}>
<a>{t("Set as admin")}</a>
</PopupMenuItem>
)}
{app.user?.is_admin ? (
user.can_upload ? (
<PopupMenuItem onClick={handleRemoveUploadPermission}>
<a>{t("Remove upload permission")}</a>
</PopupMenuItem>
) : (
<PopupMenuItem onClick={handleSetUploadPermission}>
<a>{t("Grant upload permission")}</a>
</PopupMenuItem>
)
) : null}
</ul>,
buttonRef.current!,
);
}}
>
{isLoading ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<MdMoreHoriz size={20} className="opacity-50" />
)}
</button>
<dialog id={`delete_user_dialog_${user.id}`} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Delete User")}</h3>
<p className="py-4">
{t("Are you sure you want to delete user")}{" "}
<span className="font-bold">{user.username}</span>?{" "}
{t("This action cannot be undone.")}
</p>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
<button className="btn btn-error" onClick={handleDelete}>
{t("Delete")}
</button>
</form>
</div>
</div>
</div>
</dialog>
</div>
</td>
</tr>
}
</dialog>
</div>
</td>
</tr>
);
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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