mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 04:17:23 +00:00
format
This commit is contained in:
@@ -24,31 +24,31 @@ export default tseslint.config({
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
// other options...
|
// other options...
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
|
||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// eslint.config.js
|
// eslint.config.js
|
||||||
import reactX from 'eslint-plugin-react-x'
|
import reactX from "eslint-plugin-react-x";
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
import reactDom from "eslint-plugin-react-dom";
|
||||||
|
|
||||||
export default tseslint.config({
|
export default tseslint.config({
|
||||||
plugins: {
|
plugins: {
|
||||||
// Add the react-x and react-dom plugins
|
// Add the react-x and react-dom plugins
|
||||||
'react-x': reactX,
|
"react-x": reactX,
|
||||||
'react-dom': reactDom,
|
"react-dom": reactDom,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
// other rules...
|
// other rules...
|
||||||
// Enable its recommended typescript rules
|
// Enable its recommended typescript rules
|
||||||
...reactX.configs['recommended-typescript'].rules,
|
...reactX.configs["recommended-typescript"].rules,
|
||||||
...reactDom.configs.recommended.rules,
|
...reactDom.configs.recommended.rules,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
@@ -1,33 +1,33 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<base href="/">
|
<base href="/" />
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible" />
|
||||||
<meta name="description" content="{{Description}}">
|
<meta name="description" content="{{Description}}" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
<!-- SEO meta -->
|
<!-- SEO meta -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="{{Title}}">
|
<meta name="twitter:title" content="{{Title}}" />
|
||||||
<meta name="twitter:description" content="{{Description}}">
|
<meta name="twitter:description" content="{{Description}}" />
|
||||||
<meta name="twitter:image" content="{{Preview}}">
|
<meta name="twitter:image" content="{{Preview}}" />
|
||||||
<meta property="og:title" content="{{Title}}">
|
<meta property="og:title" content="{{Title}}" />
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="{{Url}}">
|
<meta property="og:url" content="{{Url}}" />
|
||||||
<meta property="og:image" content="{{Preview}}">
|
<meta property="og:image" content="{{Preview}}" />
|
||||||
<meta property="og:description" content="{{Description}}">
|
<meta property="og:description" content="{{Description}}" />
|
||||||
<meta property="og:site_name" content={{SiteName}}>
|
<meta property="og:site_name" content="{{SiteName}}" />
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Nysoure">
|
<meta name="apple-mobile-web-app-title" content="Nysoure" />
|
||||||
<link rel="apple-touch-icon" href="/icon-192.png">
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||||
|
|
||||||
<title>{{Title}}</title>
|
<title>{{Title}}</title>
|
||||||
</head>
|
</head>
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import {User} from "./network/models.ts";
|
import { User } from "./network/models.ts";
|
||||||
|
|
||||||
interface MyWindow extends Window {
|
interface MyWindow extends Window {
|
||||||
serverName?: string;
|
serverName?: string;
|
||||||
@@ -7,7 +7,7 @@ interface MyWindow extends Window {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
appName = "Nysoure"
|
appName = "Nysoure";
|
||||||
|
|
||||||
user: User | null = null;
|
user: User | null = null;
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ class App {
|
|||||||
|
|
||||||
cloudflareTurnstileSiteKey: string | null = null;
|
cloudflareTurnstileSiteKey: string | null = null;
|
||||||
|
|
||||||
siteInfo = ""
|
siteInfo = "";
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.init();
|
this.init();
|
||||||
@@ -31,7 +31,8 @@ class App {
|
|||||||
this.token = JSON.parse(tokenJson);
|
this.token = JSON.parse(tokenJson);
|
||||||
}
|
}
|
||||||
this.appName = (window as MyWindow).serverName || this.appName;
|
this.appName = (window as MyWindow).serverName || this.appName;
|
||||||
this.cloudflareTurnstileSiteKey = (window as MyWindow).cloudflareTurnstileSiteKey || null;
|
this.cloudflareTurnstileSiteKey =
|
||||||
|
(window as MyWindow).cloudflareTurnstileSiteKey || null;
|
||||||
this.siteInfo = (window as MyWindow).siteInfo || "";
|
this.siteInfo = (window as MyWindow).siteInfo || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,4 +54,4 @@ class App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const app = new App();
|
export const app = new App();
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import {BrowserRouter, Route, Routes} from "react-router";
|
import { BrowserRouter, Route, Routes } from "react-router";
|
||||||
import LoginPage from "./pages/login_page.tsx";
|
import LoginPage from "./pages/login_page.tsx";
|
||||||
import RegisterPage from "./pages/register_page.tsx";
|
import RegisterPage from "./pages/register_page.tsx";
|
||||||
import Navigator from "./components/navigator.tsx";
|
import Navigator from "./components/navigator.tsx";
|
||||||
@@ -17,21 +17,21 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={"/login"} element={<LoginPage/>}/>
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
<Route path={"/register"} element={<RegisterPage/>}/>
|
<Route path={"/register"} element={<RegisterPage />} />
|
||||||
<Route element={<Navigator/>}>
|
<Route element={<Navigator />}>
|
||||||
<Route path={"/"} element={<HomePage/>}/>
|
<Route path={"/"} element={<HomePage />} />
|
||||||
<Route path={"/publish"} element={<PublishPage/>} />
|
<Route path={"/publish"} element={<PublishPage />} />
|
||||||
<Route path={"/search"} element={<SearchPage/>} />
|
<Route path={"/search"} element={<SearchPage />} />
|
||||||
<Route path={"/resources/:id"} element={<ResourcePage/>}/>
|
<Route path={"/resources/:id"} element={<ResourcePage />} />
|
||||||
<Route path={"/manage"} element={<ManagePage/>}/>
|
<Route path={"/manage"} element={<ManagePage />} />
|
||||||
<Route path={"/tag/:tag"} element={<TaggedResourcesPage/>}/>
|
<Route path={"/tag/:tag"} element={<TaggedResourcesPage />} />
|
||||||
<Route path={"/user/:username"} element={<UserPage/>}/>
|
<Route path={"/user/:username"} element={<UserPage />} />
|
||||||
<Route path={"/resource/edit/:rid"} element={<EditResourcePage/>}/>
|
<Route path={"/resource/edit/:rid"} element={<EditResourcePage />} />
|
||||||
<Route path={"/about"} element={<AboutPage/>}/>
|
<Route path={"/about"} element={<AboutPage />} />
|
||||||
<Route path={"/tags"} element={<TagsPage/>}/>
|
<Route path={"/tags"} element={<TagsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,6 @@
|
|||||||
import {createContext, ReactNode, useContext} from "react";
|
import { createContext, ReactNode, useContext } from "react";
|
||||||
|
|
||||||
export default function AppContext({
|
export default function AppContext({ children }: { children: ReactNode }) {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<context.Provider value={new Map<string, any>()}>
|
<context.Provider value={new Map<string, any>()}>
|
||||||
{children}
|
{children}
|
||||||
@@ -15,5 +11,5 @@ export default function AppContext({
|
|||||||
const context = createContext<Map<string, any>>(new Map<string, any>());
|
const context = createContext<Map<string, any>>(new Map<string, any>());
|
||||||
|
|
||||||
export function useAppContext() {
|
export function useAppContext() {
|
||||||
return useContext(context)
|
return useContext(context);
|
||||||
}
|
}
|
||||||
|
@@ -1,18 +1,53 @@
|
|||||||
export function ErrorAlert({ message, className }: { message: string, className?: string }) {
|
export function ErrorAlert({
|
||||||
return <div role="alert" className={`alert alert-error ${className}`}>
|
message,
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
className,
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
}: {
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
message: string;
|
||||||
</svg>
|
className?: string;
|
||||||
<span>{message}</span>
|
}) {
|
||||||
</div>;
|
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 }) {
|
export function InfoAlert({
|
||||||
return <div role="alert" className={`alert alert-info ${className}`}>
|
message,
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-6 w-6 shrink-0 stroke-current">
|
className,
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
}: {
|
||||||
</svg>
|
message: string;
|
||||||
<span>{message}</span>
|
className?: string;
|
||||||
</div>;
|
}) {
|
||||||
}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,9 +1,39 @@
|
|||||||
import {ReactNode} from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export default function Badge({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) {
|
export default function Badge({
|
||||||
return <span className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`} onClick={onClick}>{children}</span>
|
children,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BadgeAccent({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) {
|
export function BadgeAccent({
|
||||||
return <span className={`badge badge-accent text-sm ${className}`} onClick={onClick}>{children}</span>
|
children,
|
||||||
}
|
className,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`badge badge-accent text-sm ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,14 +1,28 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export default function Button({ children, onClick, className, disabled, isLoading }: { children: ReactNode, onClick?: () => void, className?: string, disabled?: boolean, isLoading?: boolean }) {
|
export default function Button({
|
||||||
return <button
|
children,
|
||||||
className={`btn ${className} ${disabled ? "btn-disabled" : ""} h-9`}
|
onClick,
|
||||||
onClick={onClick}
|
className,
|
||||||
disabled={disabled}
|
disabled,
|
||||||
>
|
isLoading,
|
||||||
{isLoading && <span className="loading loading-spinner loading-sm mr-2"></span>}
|
}: {
|
||||||
<span className="text-sm">
|
children: ReactNode;
|
||||||
{children}
|
onClick?: () => void;
|
||||||
</span>
|
className?: string;
|
||||||
</button>;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,12 +1,12 @@
|
|||||||
import {MdAdd} from "react-icons/md";
|
import { MdAdd } from "react-icons/md";
|
||||||
import {useTranslation} from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {network} from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import showToast from "./toast.ts";
|
import showToast from "./toast.ts";
|
||||||
import {useState} from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
async function uploadImages(files: File[]): Promise<number[]> {
|
async function uploadImages(files: File[]): Promise<number[]> {
|
||||||
const images: number[] = [];
|
const images: number[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const res = await network.uploadImage(file);
|
const res = await network.uploadImage(file);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
@@ -15,65 +15,83 @@ async function uploadImages(files: File[]): Promise<number[]> {
|
|||||||
showToast({
|
showToast({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: `Failed to upload image: ${res.message}`,
|
message: `Failed to upload image: ${res.message}`,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectAndUploadImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) {
|
export function SelectAndUploadImageButton({
|
||||||
const [isUploading, setUploading] = useState(false)
|
onUploaded,
|
||||||
|
}: {
|
||||||
|
onUploaded: (image: number[]) => void;
|
||||||
|
}) {
|
||||||
|
const [isUploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const addImage = () => {
|
const addImage = () => {
|
||||||
if (isUploading) {
|
if (isUploading) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const input = document.createElement("input")
|
const input = document.createElement("input");
|
||||||
input.type = "file"
|
input.type = "file";
|
||||||
input.accept = "image/*"
|
input.accept = "image/*";
|
||||||
input.multiple = true
|
input.multiple = true;
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (!input.files || input.files.length === 0) {
|
if (!input.files || input.files.length === 0) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setUploading(true)
|
setUploading(true);
|
||||||
const files = Array.from(input.files);
|
const files = Array.from(input.files);
|
||||||
const uploadedImages = await uploadImages(files);
|
const uploadedImages = await uploadImages(files);
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
if (uploadedImages.length > 0) {
|
if (uploadedImages.length > 0) {
|
||||||
onUploaded(uploadedImages);
|
onUploaded(uploadedImages);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
input.click()
|
input.click();
|
||||||
}
|
};
|
||||||
|
|
||||||
return <button className={"btn my-2"} type={"button"} onClick={addImage}>
|
return (
|
||||||
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
<button className={"btn my-2"} type={"button"} onClick={addImage}>
|
||||||
{t("Upload Image")}
|
{isUploading ? (
|
||||||
</button>
|
<span className="loading loading-spinner"></span>
|
||||||
|
) : (
|
||||||
|
<MdAdd />
|
||||||
|
)}
|
||||||
|
{t("Upload Image")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UploadClipboardImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) {
|
export function UploadClipboardImageButton({
|
||||||
const [isUploading, setUploading] = useState(false)
|
onUploaded,
|
||||||
|
}: {
|
||||||
|
onUploaded: (image: number[]) => void;
|
||||||
|
}) {
|
||||||
|
const [isUploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const addClipboardImage = async () => {
|
const addClipboardImage = async () => {
|
||||||
if (isUploading) {
|
if (isUploading) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const clipboardItems = await navigator.clipboard.read();
|
const clipboardItems = await navigator.clipboard.read();
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
for (const item of clipboardItems) {
|
for (const item of clipboardItems) {
|
||||||
console.log(item)
|
console.log(item);
|
||||||
for (const type of item.types) {
|
for (const type of item.types) {
|
||||||
if (type.startsWith("image/")) {
|
if (type.startsWith("image/")) {
|
||||||
const blob = await item.getType(type);
|
const blob = await item.getType(type);
|
||||||
files.push(new File([blob], `clipboard-image.${type.split("/")[1]}`, { type }));
|
files.push(
|
||||||
|
new File([blob], `clipboard-image.${type.split("/")[1]}`, {
|
||||||
|
type,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,15 +114,27 @@ export function UploadClipboardImageButton({onUploaded}: {onUploaded: (image: nu
|
|||||||
message: t("Failed to read clipboard image"),
|
message: t("Failed to read clipboard image"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return <button className={"btn my-2"} type={"button"} onClick={addClipboardImage}>
|
return (
|
||||||
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
<button className={"btn my-2"} type={"button"} onClick={addClipboardImage}>
|
||||||
{t("Upload Clipboard Image")}
|
{isUploading ? (
|
||||||
</button>
|
<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 [isUploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
@@ -128,7 +158,7 @@ export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode
|
|||||||
if (e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer.files.length > 0) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
let files = Array.from(e.dataTransfer.files);
|
let files = Array.from(e.dataTransfer.files);
|
||||||
files = files.filter(file => file.type.startsWith("image/"));
|
files = files.filter((file) => file.type.startsWith("image/"));
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
return;
|
return;
|
||||||
@@ -160,4 +190,4 @@ export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -11,15 +11,31 @@ interface InputProps {
|
|||||||
|
|
||||||
export default function Input(props: InputProps) {
|
export default function Input(props: InputProps) {
|
||||||
if (props.inlineLabel) {
|
if (props.inlineLabel) {
|
||||||
return <label className="input w-full">
|
return (
|
||||||
{props.label}
|
<label className="input w-full">
|
||||||
<input type={props.type} className="grow" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
|
{props.label}
|
||||||
</label>
|
<input
|
||||||
|
type={props.type}
|
||||||
|
className="grow"
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
value={props.value}
|
||||||
|
onChange={props.onChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return <fieldset className="fieldset w-full">
|
return (
|
||||||
<legend className="fieldset-legend">{props.label}</legend>
|
<fieldset className="fieldset w-full">
|
||||||
<input type={props.type} className="input w-full" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
|
<legend className="fieldset-legend">{props.label}</legend>
|
||||||
</fieldset>
|
<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) {
|
export function TextArea(props: TextAreaProps) {
|
||||||
return <fieldset className="fieldset w-full">
|
return (
|
||||||
<legend className="fieldset-legend">{props.label}</legend>
|
<fieldset className="fieldset w-full">
|
||||||
<textarea className={`textarea w-full ${props.height != undefined ? "resize-none" : ""}`} value={props.value} onChange={props.onChange} style={{
|
<legend className="fieldset-legend">{props.label}</legend>
|
||||||
height: props.height,
|
<textarea
|
||||||
}} />
|
className={`textarea w-full ${props.height != undefined ? "resize-none" : ""}`}
|
||||||
</fieldset>
|
value={props.value}
|
||||||
}
|
onChange={props.onChange}
|
||||||
|
style={{
|
||||||
|
height: props.height,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
const {t} = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return <div className={"flex justify-center py-4"}>
|
return (
|
||||||
<span className="loading loading-spinner progress-primary loading-lg mr-2"></span>
|
<div className={"flex justify-center py-4"}>
|
||||||
<span>{t("Loading")}</span>
|
<span className="loading loading-spinner progress-primary loading-lg mr-2"></span>
|
||||||
</div>;
|
<span>{t("Loading")}</span>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -2,16 +2,21 @@ import { app } from "../app.ts";
|
|||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { useNavigate, useOutlet } from "react-router";
|
import { useNavigate, useOutlet } from "react-router";
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import {MdArrowUpward, MdOutlinePerson, MdSearch, MdSettings} from "react-icons/md";
|
import {
|
||||||
|
MdArrowUpward,
|
||||||
|
MdOutlinePerson,
|
||||||
|
MdSearch,
|
||||||
|
MdSettings,
|
||||||
|
} from "react-icons/md";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import UploadingSideBar from "./uploading_side_bar.tsx";
|
import UploadingSideBar from "./uploading_side_bar.tsx";
|
||||||
import { IoLogoGithub } from "react-icons/io";
|
import { IoLogoGithub } from "react-icons/io";
|
||||||
import {useAppContext} from "./AppContext.tsx";
|
import { useAppContext } from "./AppContext.tsx";
|
||||||
|
|
||||||
export default function Navigator() {
|
export default function Navigator() {
|
||||||
const outlet = useOutlet()
|
const outlet = useOutlet();
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [key, setKey] = useState(0);
|
const [key, setKey] = useState(0);
|
||||||
|
|
||||||
@@ -25,86 +30,154 @@ export default function Navigator() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<FloatingToTopButton/>
|
<>
|
||||||
<div className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10" key={key}>
|
<FloatingToTopButton />
|
||||||
<div className={"flex-1 max-w-7xl mx-auto flex items-center"}>
|
<div
|
||||||
<div className="dropdown">
|
className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10"
|
||||||
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle lg:hidden">
|
key={key}
|
||||||
<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 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>
|
</div>
|
||||||
<ul id={"navi_menu"}
|
<div>
|
||||||
tabIndex={0}
|
<button
|
||||||
className="menu menu-md dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
|
className="btn btn-ghost text-xl"
|
||||||
<li onClick={() => {
|
onClick={() => {
|
||||||
const menu = document.getElementById("navi_menu") as HTMLElement;
|
appContext.clear();
|
||||||
menu.blur();
|
navigate(`/`, { replace: true });
|
||||||
navigate("/");
|
}}
|
||||||
}}><a>{t("Home")}</a></li>
|
>
|
||||||
<li onClick={() => {
|
{app.appName}
|
||||||
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}/>
|
|
||||||
</button>
|
</button>
|
||||||
}
|
</div>
|
||||||
<button className={"btn btn-circle btn-ghost"} onClick={() => {
|
<div className="hidden lg:flex">
|
||||||
window.open("https://github.com/wgh136/nysoure", "_blank");
|
<ul className="menu menu-horizontal px-1">
|
||||||
}}>
|
<li
|
||||||
<IoLogoGithub size={24}/>
|
onClick={() => {
|
||||||
</button>
|
navigate("/");
|
||||||
{
|
}}
|
||||||
app.isLoggedIn() ? <UserButton/> :
|
>
|
||||||
<button className={"btn btn-primary btn-square btn-soft"} onClick={() => {
|
<a>{t("Home")}</a>
|
||||||
navigate("/login");
|
</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>
|
<MdOutlinePerson size={24}></MdOutlinePerson>
|
||||||
</button>
|
</button>
|
||||||
}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<navigatorContext.Provider value={naviContext}>
|
||||||
<navigatorContext.Provider value={naviContext}>
|
<div className={"max-w-7xl mx-auto pt-16"}>{outlet}</div>
|
||||||
<div className={"max-w-7xl mx-auto pt-16"}>
|
</navigatorContext.Provider>
|
||||||
{outlet}
|
</>
|
||||||
</div>
|
);
|
||||||
</navigatorContext.Provider>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavigatorContext {
|
interface NavigatorContext {
|
||||||
@@ -114,8 +187,8 @@ interface NavigatorContext {
|
|||||||
const navigatorContext = createContext<NavigatorContext>({
|
const navigatorContext = createContext<NavigatorContext>({
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
// do nothing
|
// do nothing
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export function useNavigator() {
|
export function useNavigator() {
|
||||||
return useContext(navigatorContext);
|
return useContext(navigatorContext);
|
||||||
@@ -124,61 +197,95 @@ export function useNavigator() {
|
|||||||
function UserButton() {
|
function UserButton() {
|
||||||
let avatar = "./avatar.png";
|
let avatar = "./avatar.png";
|
||||||
if (app.user) {
|
if (app.user) {
|
||||||
avatar = network.getUserAvatar(app.user)
|
avatar = network.getUserAvatar(app.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<div className="dropdown dropdown-end">
|
<>
|
||||||
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle avatar">
|
<div className="dropdown dropdown-end">
|
||||||
<div className="w-10 rounded-full">
|
<div
|
||||||
<img
|
tabIndex={0}
|
||||||
alt="Avatar"
|
role="button"
|
||||||
src={avatar} />
|
className="btn btn-ghost btn-circle avatar"
|
||||||
|
>
|
||||||
|
<div className="w-10 rounded-full">
|
||||||
|
<img alt="Avatar" src={avatar} />
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<ul
|
<dialog id="confirm_logout" className="modal">
|
||||||
id={"navi_dropdown_menu"}
|
<div className="modal-box">
|
||||||
tabIndex={0}
|
<h3 className="text-lg font-bold">{t("Log out")}</h3>
|
||||||
className="menu dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
|
<p className="py-4">{t("Are you sure you want to log out?")}</p>
|
||||||
<li><a onClick={() => {
|
<div className="modal-action">
|
||||||
navigate(`/user/${app.user?.username}`);
|
<form method="dialog">
|
||||||
const menu = document.getElementById("navi_dropdown_menu") as HTMLUListElement;
|
<button className="btn">{t("Cancel")}</button>
|
||||||
menu.blur();
|
<button
|
||||||
}}>{t("My Profile")}</a></li>
|
className="btn btn-error mx-2"
|
||||||
<li><a onClick={() => {
|
type={"button"}
|
||||||
navigate(`/publish`);
|
onClick={() => {
|
||||||
const menu = document.getElementById("navi_dropdown_menu") as HTMLUListElement;
|
app.user = null;
|
||||||
menu.blur();
|
app.token = null;
|
||||||
}}>{t("Publish")}</a></li>
|
app.saveData();
|
||||||
<li><a onClick={() => {
|
navigate(`/login`, { replace: true });
|
||||||
const dialog = document.getElementById("confirm_logout") as HTMLDialogElement;
|
}}
|
||||||
dialog.showModal();
|
>
|
||||||
}}>{t("Log out")}</a></li>
|
{t("Confirm")}
|
||||||
</ul>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
<dialog id="confirm_logout" className="modal">
|
</div>
|
||||||
<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() {
|
function SearchBar() {
|
||||||
@@ -212,64 +319,94 @@ function SearchBar() {
|
|||||||
}
|
}
|
||||||
const replace = window.location.pathname === "/search";
|
const replace = window.location.pathname === "/search";
|
||||||
navigate(`/search?keyword=${search}`, { replace: replace });
|
navigate(`/search?keyword=${search}`, { replace: replace });
|
||||||
}
|
};
|
||||||
|
|
||||||
const searchField = <label className={`input input-primary ${small ? "w-full" : "w-64"}`}>
|
const searchField = (
|
||||||
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<label className={`input input-primary ${small ? "w-full" : "w-64"}`}>
|
||||||
<g
|
<svg
|
||||||
stroke-linejoin="round"
|
className="h-[1em] opacity-50"
|
||||||
stroke-linecap="round"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-width="2.5"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
>
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<g
|
||||||
<path d="m21 21-4.3-4.3"></path>
|
stroke-linejoin="round"
|
||||||
</g>
|
stroke-linecap="round"
|
||||||
</svg>
|
stroke-width="2.5"
|
||||||
<form className={"w-full"} onSubmit={(e) => {
|
fill="none"
|
||||||
e.preventDefault();
|
stroke="currentColor"
|
||||||
doSearch();
|
>
|
||||||
}}>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<input type="search" className={"w-full"} required placeholder={t("Search")} value={search} onChange={(e) => setSearch(e.target.value)} />
|
<path d="m21 21-4.3-4.3"></path>
|
||||||
</form>
|
</g>
|
||||||
</label>
|
</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) {
|
if (small) {
|
||||||
return <>
|
return (
|
||||||
<button className={"btn btn-circle btn-ghost"} onClick={() => {
|
<>
|
||||||
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
|
<button
|
||||||
dialog.showModal();
|
className={"btn btn-circle btn-ghost"}
|
||||||
}}>
|
onClick={() => {
|
||||||
<MdSearch size={24} />
|
const dialog = document.getElementById(
|
||||||
</button>
|
"search_dialog",
|
||||||
<dialog id="search_dialog" className="modal">
|
) as HTMLDialogElement;
|
||||||
<div className="modal-box">
|
dialog.showModal();
|
||||||
<form method="dialog">
|
}}
|
||||||
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
>
|
||||||
</form>
|
<MdSearch size={24} />
|
||||||
<h3 className="text-lg font-bold">{t("Search")}</h3>
|
</button>
|
||||||
<div className={"h-4"} />
|
<dialog id="search_dialog" className="modal">
|
||||||
{searchField}
|
<div className="modal-box">
|
||||||
<div className={"h-4"} />
|
<form method="dialog">
|
||||||
<div className={"flex flex-row-reverse"}>
|
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
|
||||||
<button className={"btn btn-primary"} onClick={() => {
|
✕
|
||||||
if (search.length === 0) {
|
</button>
|
||||||
return;
|
</form>
|
||||||
}
|
<h3 className="text-lg font-bold">{t("Search")}</h3>
|
||||||
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
|
<div className={"h-4"} />
|
||||||
dialog.close();
|
{searchField}
|
||||||
doSearch();
|
<div className={"h-4"} />
|
||||||
}}>
|
<div className={"flex flex-row-reverse"}>
|
||||||
{t("Search")}
|
<button
|
||||||
</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>
|
||||||
</div>
|
</dialog>
|
||||||
</dialog>
|
</>
|
||||||
</>
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchField
|
return searchField;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FloatingToTopButton() {
|
function FloatingToTopButton() {
|
||||||
@@ -293,9 +430,14 @@ function FloatingToTopButton() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <button className={`btn btn-circle btn-soft btn-secondary border shadow-lg btn-lg fixed right-4 ${visible ? "bottom-4" : "-bottom-12"} transition-all z-50`} onClick={() => {
|
return (
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
<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`}
|
||||||
<MdArrowUpward size={20}/>
|
onClick={() => {
|
||||||
</button>;
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}}
|
||||||
|
>
|
||||||
|
<MdArrowUpward size={20} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,48 +1,106 @@
|
|||||||
import {ReactNode} from "react";
|
import { ReactNode } from "react";
|
||||||
import {MdChevronLeft, MdChevronRight} from "react-icons/md";
|
import { MdChevronLeft, MdChevronRight } from "react-icons/md";
|
||||||
|
|
||||||
export default function Pagination({page, setPage, totalPages}: {
|
export default function Pagination({
|
||||||
page: number,
|
page,
|
||||||
setPage: (page: number) => void,
|
setPage,
|
||||||
totalPages: number
|
totalPages,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
totalPages: number;
|
||||||
}) {
|
}) {
|
||||||
const items: ReactNode[] = [];
|
const items: ReactNode[] = [];
|
||||||
|
|
||||||
if (page > 1) {
|
if (page > 1) {
|
||||||
items.push(<button key={"btn-1"} className="join-item btn" onClick={() => setPage(1)}>1</button>);
|
items.push(
|
||||||
|
<button
|
||||||
|
key={"btn-1"}
|
||||||
|
className="join-item btn"
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (page - 2 > 1) {
|
if (page - 2 > 1) {
|
||||||
items.push(<button key={"btn-2"} className="join-item btn">...</button>);
|
items.push(
|
||||||
|
<button key={"btn-2"} className="join-item btn">
|
||||||
|
...
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (page - 1 > 1) {
|
if (page - 1 > 1) {
|
||||||
items.push(<button key={"btn-3"} className="join-item btn" onClick={() => setPage(page - 1)}>{page - 1}</button>);
|
items.push(
|
||||||
|
<button
|
||||||
|
key={"btn-3"}
|
||||||
|
className="join-item btn"
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
>
|
||||||
|
{page - 1}
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
items.push(<button key={"btn-4"} className="join-item btn btn-active">{page}</button>);
|
items.push(
|
||||||
|
<button key={"btn-4"} className="join-item btn btn-active">
|
||||||
|
{page}
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
if (page + 1 < totalPages) {
|
if (page + 1 < totalPages) {
|
||||||
items.push(<button key={"btn-5"} className="join-item btn" onClick={() => setPage(page + 1)}>{page + 1}</button>);
|
items.push(
|
||||||
|
<button
|
||||||
|
key={"btn-5"}
|
||||||
|
className="join-item btn"
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
>
|
||||||
|
{page + 1}
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (page + 2 < totalPages) {
|
if (page + 2 < totalPages) {
|
||||||
items.push(<button key={"btn-6"} className="join-item btn">...</button>);
|
items.push(
|
||||||
|
<button key={"btn-6"} className="join-item btn">
|
||||||
|
...
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (page < totalPages) {
|
if (page < totalPages) {
|
||||||
items.push(<button key={"btn-7"} className="join-item btn" onClick={() => setPage(totalPages)}>{totalPages}</button>);
|
items.push(
|
||||||
|
<button
|
||||||
|
key={"btn-7"}
|
||||||
|
className="join-item btn"
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
>
|
||||||
|
{totalPages}
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="join shadow rounded-field">
|
return (
|
||||||
<button key={"btn-prev"} className={`join-item btn`} onClick={() => {
|
<div className="join shadow rounded-field">
|
||||||
if (page > 1) {
|
<button
|
||||||
setPage(page - 1);
|
key={"btn-prev"}
|
||||||
}
|
className={`join-item btn`}
|
||||||
}}>
|
onClick={() => {
|
||||||
<MdChevronLeft size={20} className="opacity-50"/>
|
if (page > 1) {
|
||||||
</button>
|
setPage(page - 1);
|
||||||
{items}
|
}
|
||||||
<button key={"btn-next"} className={`join-item btn`} onClick={() => {
|
}}
|
||||||
if (page < totalPages) {
|
>
|
||||||
setPage(page + 1);
|
<MdChevronLeft size={20} className="opacity-50" />
|
||||||
}
|
</button>
|
||||||
}}>
|
{items}
|
||||||
<MdChevronRight size={20} className="opacity-50"/>
|
<button
|
||||||
</button>
|
key={"btn-next"}
|
||||||
</div>
|
className={`join-item btn`}
|
||||||
}
|
onClick={() => {
|
||||||
|
if (page < totalPages) {
|
||||||
|
setPage(page + 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdChevronRight size={20} className="opacity-50" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
export default function showPopup(content: React.ReactNode, element: HTMLElement) {
|
export default function showPopup(
|
||||||
|
content: React.ReactNode,
|
||||||
|
element: HTMLElement,
|
||||||
|
) {
|
||||||
const eRect = element.getBoundingClientRect();
|
const eRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
@@ -39,23 +42,33 @@ export default function showPopup(content: React.ReactNode, element: HTMLElement
|
|||||||
mask.onclick = close;
|
mask.onclick = close;
|
||||||
document.body.appendChild(mask);
|
document.body.appendChild(mask);
|
||||||
|
|
||||||
createRoot(div).render(<context.Provider value={close}>
|
createRoot(div).render(
|
||||||
{content}
|
<context.Provider value={close}>{content}</context.Provider>,
|
||||||
</context.Provider>)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = React.createContext<() => void>(() => { });
|
const context = React.createContext<() => void>(() => {});
|
||||||
|
|
||||||
export function useClosePopup() {
|
export function useClosePopup() {
|
||||||
return React.useContext(context);
|
return React.useContext(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PopupMenuItem({ children, onClick }: { children: React.ReactNode, onClick: () => void }) {
|
export function PopupMenuItem({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
const close = useClosePopup();
|
const close = useClosePopup();
|
||||||
return <li onClick={() => {
|
return (
|
||||||
close();
|
<li
|
||||||
onClick();
|
onClick={() => {
|
||||||
}}>
|
close();
|
||||||
{children}
|
onClick();
|
||||||
</li>
|
}}
|
||||||
}
|
>
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -4,48 +4,57 @@ import { useNavigate } from "react-router";
|
|||||||
import Badge from "./badge.tsx";
|
import Badge from "./badge.tsx";
|
||||||
|
|
||||||
export default function ResourceCard({ resource }: { resource: Resource }) {
|
export default function ResourceCard({ resource }: { resource: Resource }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
|
|
||||||
let tags = resource.tags
|
let tags = resource.tags;
|
||||||
if (tags.length > 10) {
|
if (tags.length > 10) {
|
||||||
tags = tags.slice(0, 10)
|
tags = tags.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={"p-2 cursor-pointer"} onClick={() => {
|
return (
|
||||||
navigate(`/resources/${resource.id}`)
|
<div
|
||||||
}}>
|
className={"p-2 cursor-pointer"}
|
||||||
<div className={"card shadow hover:shadow-md transition-shadow"}>
|
onClick={() => {
|
||||||
{
|
navigate(`/resources/${resource.id}`);
|
||||||
resource.image != null && <figure>
|
}}
|
||||||
<img
|
>
|
||||||
src={network.getImageUrl(resource.image.id)}
|
<div className={"card shadow hover:shadow-md transition-shadow"}>
|
||||||
alt="cover" style={{
|
{resource.image != null && (
|
||||||
width: "100%",
|
<figure>
|
||||||
aspectRatio: resource.image.width / resource.image.height,
|
<img
|
||||||
}}/>
|
src={network.getImageUrl(resource.image.id)}
|
||||||
</figure>
|
alt="cover"
|
||||||
}
|
style={{
|
||||||
<div className="flex flex-col p-4">
|
width: "100%",
|
||||||
<h2 className="card-title">{resource.title}</h2>
|
aspectRatio: resource.image.width / resource.image.height,
|
||||||
<div className="h-2"></div>
|
}}
|
||||||
<p>
|
/>
|
||||||
{
|
</figure>
|
||||||
tags.map((tag) => {
|
)}
|
||||||
return <Badge key={tag.id} className={"m-0.5"}>{tag.name}</Badge>
|
<div className="flex flex-col p-4">
|
||||||
})
|
<h2 className="card-title">{resource.title}</h2>
|
||||||
}
|
<div className="h-2"></div>
|
||||||
</p>
|
<p>
|
||||||
<div className="h-2"></div>
|
{tags.map((tag) => {
|
||||||
<div className="flex items-center">
|
return (
|
||||||
<div className="avatar">
|
<Badge key={tag.id} className={"m-0.5"}>
|
||||||
<div className="w-6 rounded-full">
|
{tag.name}
|
||||||
<img src={network.getUserAvatar(resource.author)} />
|
</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>
|
||||||
|
<div className="w-2"></div>
|
||||||
|
<div className="text-sm">{resource.author.username}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-2"></div>
|
|
||||||
<div className="text-sm">{resource.author.username}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,69 +1,80 @@
|
|||||||
import {PageResponse, Resource} from "../network/models.ts";
|
import { PageResponse, Resource } from "../network/models.ts";
|
||||||
import {useCallback, useEffect, useRef, useState} from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import showToast from "./toast.ts";
|
import showToast from "./toast.ts";
|
||||||
import ResourceCard from "./resource_card.tsx";
|
import ResourceCard from "./resource_card.tsx";
|
||||||
import {Masonry, useInfiniteLoader} from "masonic";
|
import { Masonry, useInfiniteLoader } from "masonic";
|
||||||
import Loading from "./loading.tsx";
|
import Loading from "./loading.tsx";
|
||||||
import {useAppContext} from "./AppContext.tsx";
|
import { useAppContext } from "./AppContext.tsx";
|
||||||
|
|
||||||
export default function ResourcesView({loader, storageKey}: {loader: (page: number) => Promise<PageResponse<Resource>>, storageKey?: string}) {
|
export default function ResourcesView({
|
||||||
const [data, setData] = useState<Resource[]>([])
|
loader,
|
||||||
const pageRef = useRef(1)
|
storageKey,
|
||||||
const totalPagesRef = useRef(1)
|
}: {
|
||||||
const isLoadingRef = useRef(false)
|
loader: (page: number) => Promise<PageResponse<Resource>>;
|
||||||
|
storageKey?: string;
|
||||||
const appContext = useAppContext()
|
}) {
|
||||||
|
const [data, setData] = useState<Resource[]>([]);
|
||||||
|
const pageRef = useRef(1);
|
||||||
|
const totalPagesRef = useRef(1);
|
||||||
|
const isLoadingRef = useRef(false);
|
||||||
|
|
||||||
|
const appContext = useAppContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (storageKey) {
|
if (storageKey) {
|
||||||
const data = appContext.get(storageKey + "/data")
|
const data = appContext.get(storageKey + "/data");
|
||||||
const page = appContext.get(storageKey + "/page")
|
const page = appContext.get(storageKey + "/page");
|
||||||
const totalPages = appContext.get(storageKey + "/totalPages")
|
const totalPages = appContext.get(storageKey + "/totalPages");
|
||||||
console.log("loading data", data, page, totalPages)
|
console.log("loading data", data, page, totalPages);
|
||||||
if (data) {
|
if (data) {
|
||||||
setData(data)
|
setData(data);
|
||||||
pageRef.current = page
|
pageRef.current = page;
|
||||||
totalPagesRef.current = totalPages
|
totalPagesRef.current = totalPages;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [appContext, storageKey]);
|
}, [appContext, storageKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (storageKey && data.length > 0) {
|
if (storageKey && data.length > 0) {
|
||||||
console.log("storing data", data)
|
console.log("storing data", data);
|
||||||
appContext.set(storageKey + "/data", data)
|
appContext.set(storageKey + "/data", data);
|
||||||
appContext.set(storageKey + "/page", pageRef.current)
|
appContext.set(storageKey + "/page", pageRef.current);
|
||||||
appContext.set(storageKey + "/totalPages", totalPagesRef.current)
|
appContext.set(storageKey + "/totalPages", totalPagesRef.current);
|
||||||
}
|
}
|
||||||
}, [appContext, data, storageKey]);
|
}, [appContext, data, storageKey]);
|
||||||
|
|
||||||
const loadPage = useCallback(async () => {
|
const loadPage = useCallback(async () => {
|
||||||
if (pageRef.current > totalPagesRef.current) return
|
if (pageRef.current > totalPagesRef.current) return;
|
||||||
if (isLoadingRef.current) return
|
if (isLoadingRef.current) return;
|
||||||
isLoadingRef.current = true
|
isLoadingRef.current = true;
|
||||||
const res = await loader(pageRef.current)
|
const res = await loader(pageRef.current);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
showToast({message: res.message, type: "error"})
|
showToast({ message: res.message, type: "error" });
|
||||||
} else {
|
} else {
|
||||||
isLoadingRef.current = false
|
isLoadingRef.current = false;
|
||||||
pageRef.current = pageRef.current + 1
|
pageRef.current = pageRef.current + 1;
|
||||||
totalPagesRef.current = res.totalPages ?? 1
|
totalPagesRef.current = res.totalPages ?? 1;
|
||||||
setData((prev) => [...prev, ...res.data!])
|
setData((prev) => [...prev, ...res.data!]);
|
||||||
}
|
}
|
||||||
}, [loader])
|
}, [loader]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPage()
|
loadPage();
|
||||||
}, [loadPage]);
|
}, [loadPage]);
|
||||||
|
|
||||||
const maybeLoadMore = useInfiniteLoader(loadPage)
|
const maybeLoadMore = useInfiniteLoader(loadPage);
|
||||||
|
|
||||||
return <div className={"px-2 pt-2"}>
|
return (
|
||||||
<Masonry onRender={maybeLoadMore} columnWidth={300} items={data} render={(e) => {
|
<div className={"px-2 pt-2"}>
|
||||||
return <ResourceCard resource={e.data} key={e.data.id}/>
|
<Masonry
|
||||||
} }></Masonry>
|
onRender={maybeLoadMore}
|
||||||
{
|
columnWidth={300}
|
||||||
pageRef.current <= totalPagesRef.current && <Loading/>
|
items={data}
|
||||||
}
|
render={(e) => {
|
||||||
</div>
|
return <ResourceCard resource={e.data} key={e.data.id} />;
|
||||||
}
|
}}
|
||||||
|
></Masonry>
|
||||||
|
{pageRef.current <= totalPagesRef.current && <Loading />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,229 +1,332 @@
|
|||||||
import {Tag} from "../network/models.ts";
|
import { Tag } from "../network/models.ts";
|
||||||
import {useRef, useState} from "react";
|
import { useRef, useState } from "react";
|
||||||
import {useTranslation} from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {network} from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import {LuInfo} from "react-icons/lu";
|
import { LuInfo } from "react-icons/lu";
|
||||||
import {MdSearch} from "react-icons/md";
|
import { MdSearch } from "react-icons/md";
|
||||||
import Button from "./button.tsx";
|
import Button from "./button.tsx";
|
||||||
import Input, {TextArea} from "./input.tsx";
|
import Input, { TextArea } from "./input.tsx";
|
||||||
import {ErrorAlert} from "./alert.tsx";
|
import { ErrorAlert } from "./alert.tsx";
|
||||||
|
|
||||||
export default function TagInput({ onAdd, mainTag }: { onAdd: (tag: Tag) => void, mainTag?: boolean }) {
|
export default function TagInput({
|
||||||
const [keyword, setKeyword] = useState<string>("")
|
onAdd,
|
||||||
const [tags, setTags] = useState<Tag[]>([])
|
mainTag,
|
||||||
const [error, setError] = useState<string | null>(null)
|
}: {
|
||||||
const [isLoading, setLoading] = useState(false)
|
onAdd: (tag: Tag) => void;
|
||||||
|
mainTag?: boolean;
|
||||||
|
}) {
|
||||||
|
const [keyword, setKeyword] = useState<string>("");
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setLoading] = useState(false);
|
||||||
|
|
||||||
const debounce = useRef(new Debounce(500))
|
const debounce = useRef(new Debounce(500));
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const searchTags = async (keyword: string) => {
|
const searchTags = async (keyword: string) => {
|
||||||
if (keyword.length === 0) {
|
if (keyword.length === 0) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setTags([])
|
setTags([]);
|
||||||
setError(null)
|
setError(null);
|
||||||
const res = await network.searchTags(keyword, mainTag)
|
const res = await network.searchTags(keyword, mainTag);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.message)
|
setError(res.message);
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setTags(res.data!)
|
setTags(res.data!);
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleChange = async (v: string) => {
|
const handleChange = async (v: string) => {
|
||||||
setKeyword(v)
|
setKeyword(v);
|
||||||
setTags([])
|
setTags([]);
|
||||||
setError(null)
|
setError(null);
|
||||||
if (v.length !== 0) {
|
if (v.length !== 0) {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
debounce.current.run(() => searchTags(v))
|
debounce.current.run(() => searchTags(v));
|
||||||
} else {
|
} else {
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
debounce.current.cancel()
|
debounce.current.cancel();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCreateTag = async (name: string) => {
|
const handleCreateTag = async (name: string) => {
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
const res = await network.createTag(name)
|
const res = await network.createTag(name);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.message)
|
setError(res.message);
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
onAdd(res.data!)
|
onAdd(res.data!);
|
||||||
setKeyword("")
|
setKeyword("");
|
||||||
setTags([])
|
setTags([]);
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
const input = document.getElementById("search_tags_input") as HTMLInputElement
|
const input = document.getElementById(
|
||||||
input.blur()
|
"search_tags_input",
|
||||||
}
|
) as HTMLInputElement;
|
||||||
|
input.blur();
|
||||||
|
};
|
||||||
|
|
||||||
let dropdownContent
|
let dropdownContent;
|
||||||
if (error) {
|
if (error) {
|
||||||
dropdownContent = <div className="alert alert-error my-2">
|
dropdownContent = (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
<div className="alert alert-error my-2">
|
||||||
viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-6 w-6 shrink-0 stroke-current"
|
||||||
</svg>
|
fill="none"
|
||||||
<span>{error}</span>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
>
|
||||||
|
<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) {
|
} else if (!keyword) {
|
||||||
dropdownContent = <div className="flex flex-row py-2 px-4">
|
dropdownContent = (
|
||||||
<LuInfo size={20} />
|
<div className="flex flex-row py-2 px-4">
|
||||||
<span className={"w-2"} />
|
<LuInfo size={20} />
|
||||||
<span className={"flex-1"}>{t("Please enter a search keyword")}</span>
|
<span className={"w-2"} />
|
||||||
</div>
|
<span className={"flex-1"}>{t("Please enter a search keyword")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
} else if (isLoading) {
|
} else if (isLoading) {
|
||||||
dropdownContent = <div className="flex flex-row py-2 px-4">
|
dropdownContent = (
|
||||||
<span className={"loading loading-spinner loading-sm"}></span>
|
<div className="flex flex-row py-2 px-4">
|
||||||
<span className={"w-2"} />
|
<span className={"loading loading-spinner loading-sm"}></span>
|
||||||
<span className={"flex-1"}>{t("Searching...")}</span>
|
<span className={"w-2"} />
|
||||||
</div>
|
<span className={"flex-1"}>{t("Searching...")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined
|
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined;
|
||||||
dropdownContent = <>
|
dropdownContent = (
|
||||||
{
|
<>
|
||||||
tags.map((t) => {
|
{tags.map((t) => {
|
||||||
return <li key={t.id} onClick={() => {
|
return (
|
||||||
onAdd(t);
|
<li
|
||||||
setKeyword("")
|
key={t.id}
|
||||||
setTags([])
|
onClick={() => {
|
||||||
const input = document.getElementById("search_tags_input") as HTMLInputElement
|
onAdd(t);
|
||||||
input.blur()
|
setKeyword("");
|
||||||
}}><a>
|
setTags([]);
|
||||||
<span>{t.name}</span>
|
const input = document.getElementById(
|
||||||
{t.type && <span className="badge badge-secondary badge-sm ml-2 text-xs">{t.type}</span>}
|
"search_tags_input",
|
||||||
</a></li>
|
) as HTMLInputElement;
|
||||||
})
|
input.blur();
|
||||||
}
|
}}
|
||||||
{
|
>
|
||||||
!haveExactMatch && <li onClick={() => {
|
<a>
|
||||||
handleCreateTag(keyword)
|
<span>{t.name}</span>
|
||||||
}}><a>{t("Create Tag")}: {keyword}</a></li>
|
{t.type && (
|
||||||
}
|
<span className="badge badge-secondary badge-sm ml-2 text-xs">
|
||||||
</>
|
{t.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!haveExactMatch && (
|
||||||
|
<li
|
||||||
|
onClick={() => {
|
||||||
|
handleCreateTag(keyword);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a>
|
||||||
|
{t("Create Tag")}: {keyword}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={"dropdown dropdown-end"}>
|
return (
|
||||||
<label className="input w-64">
|
<div className={"dropdown dropdown-end"}>
|
||||||
<MdSearch size={18}/>
|
<label className="input w-64">
|
||||||
<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)} />
|
<MdSearch size={18} />
|
||||||
</label>
|
<input
|
||||||
<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">
|
autoComplete={"off"}
|
||||||
{dropdownContent}
|
id={"search_tags_input"}
|
||||||
</ul>
|
tabIndex={0}
|
||||||
</div>
|
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 {
|
class Debounce {
|
||||||
private timer: number | null = null
|
private timer: number | null = null;
|
||||||
private readonly delay: number
|
private readonly delay: number;
|
||||||
|
|
||||||
constructor(delay: number) {
|
constructor(delay: number) {
|
||||||
this.delay = delay
|
this.delay = delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
run(callback: () => void) {
|
run(callback: () => void) {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearTimeout(this.timer)
|
clearTimeout(this.timer);
|
||||||
}
|
}
|
||||||
this.timer = setTimeout(() => {
|
this.timer = setTimeout(() => {
|
||||||
callback()
|
callback();
|
||||||
}, this.delay)
|
}, this.delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearTimeout(this.timer)
|
clearTimeout(this.timer);
|
||||||
this.timer = null
|
this.timer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuickAddTagDialog({ onAdded }: { onAdded: (tags: Tag[]) => void }) {
|
export function QuickAddTagDialog({
|
||||||
const {t} = useTranslation();
|
onAdded,
|
||||||
|
}: {
|
||||||
|
onAdded: (tags: Tag[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [text, setText] = useState<string>("")
|
const [text, setText] = useState<string>("");
|
||||||
|
|
||||||
const [type, setType] = useState<string>("")
|
const [type, setType] = useState<string>("");
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [separator, setSeparator] = useState<string>(",")
|
const [separator, setSeparator] = useState<string>(",");
|
||||||
|
|
||||||
const [isLoading, setLoading] = useState(false)
|
const [isLoading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (text.trim().length === 0) {
|
if (text.trim().length === 0) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setError(null)
|
setError(null);
|
||||||
const names = text.split(separator).filter((n) => n.length > 0)
|
const names = text.split(separator).filter((n) => n.length > 0);
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
const res = await network.getOrCreateTags(names, type)
|
const res = await network.getOrCreateTags(names, type);
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
setError(res.message)
|
setError(res.message);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const tags = res.data!
|
const tags = res.data!;
|
||||||
onAdded(tags)
|
onAdded(tags);
|
||||||
setText("")
|
setText("");
|
||||||
setType("")
|
setType("");
|
||||||
const dialog = document.getElementById("quick_add_tag_dialog") as HTMLDialogElement
|
const dialog = document.getElementById(
|
||||||
dialog.close()
|
"quick_add_tag_dialog",
|
||||||
}
|
) as HTMLDialogElement;
|
||||||
|
dialog.close();
|
||||||
|
};
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<Button className={"btn-soft btn-primary"} onClick={() => {
|
<>
|
||||||
const dialog = document.getElementById("quick_add_tag_dialog") as HTMLDialogElement
|
<Button
|
||||||
dialog.showModal()
|
className={"btn-soft btn-primary"}
|
||||||
}}>{t("Quick Add")}</Button>
|
onClick={() => {
|
||||||
<dialog id="quick_add_tag_dialog" className="modal">
|
const dialog = document.getElementById(
|
||||||
<div className="modal-box">
|
"quick_add_tag_dialog",
|
||||||
<h3 className="font-bold text-lg">{t('Add Tags')}</h3>
|
) as HTMLDialogElement;
|
||||||
<p className="py-2 text-sm">
|
dialog.showModal();
|
||||||
{t("Input tags separated by separator.")}
|
}}
|
||||||
<br/>
|
>
|
||||||
{t("If the tag does not exist, it will be created automatically.")}
|
{t("Quick Add")}
|
||||||
<br/>
|
</Button>
|
||||||
{t("Optionally, you can specify a type for the new tags.")}
|
<dialog id="quick_add_tag_dialog" className="modal">
|
||||||
</p>
|
<div className="modal-box">
|
||||||
<p className={"flex my-2"}>
|
<h3 className="font-bold text-lg">{t("Add Tags")}</h3>
|
||||||
<span className={"flex-1"}>{t("Separator")}:</span>
|
<p className="py-2 text-sm">
|
||||||
<label className="label text-sm mx-2">
|
{t("Input tags separated by separator.")}
|
||||||
<input type="radio" name="radio-1" className="radio radio-primary" checked={separator == ","} onChange={() => setSeparator(",")}/>
|
<br />
|
||||||
Comma
|
{t("If the tag does not exist, it will be created automatically.")}
|
||||||
</label>
|
<br />
|
||||||
<label className="label text-sm mx-2">
|
{t("Optionally, you can specify a type for the new tags.")}
|
||||||
<input type="radio" name="radio-2" className="radio radio-primary" checked={separator == ";"} onChange={() => setSeparator(";")}/>
|
</p>
|
||||||
Semicolon
|
<p className={"flex my-2"}>
|
||||||
</label>
|
<span className={"flex-1"}>{t("Separator")}:</span>
|
||||||
<label className="label text-sm mx-2">
|
<label className="label text-sm mx-2">
|
||||||
<input type="radio" name="radio-3" className="radio radio-primary" checked={separator == " "} onChange={() => setSeparator(" ")}/>
|
<input
|
||||||
Space
|
type="radio"
|
||||||
</label>
|
name="radio-1"
|
||||||
</p>
|
className="radio radio-primary"
|
||||||
<TextArea value={text} onChange={(e) => setText(e.target.value)} label={"Tags"}/>
|
checked={separator == ","}
|
||||||
<Input value={type} onChange={(e) => setType(e.target.value)} label={"Type"}/>
|
onChange={() => setSeparator(",")}
|
||||||
{error && <ErrorAlert className={"mt-2"} message={error}/>}
|
/>
|
||||||
<div className="modal-action">
|
Comma
|
||||||
<form method="dialog">
|
</label>
|
||||||
<Button className="btn">{t("Cancel")}</Button>
|
<label className="label text-sm mx-2">
|
||||||
</form>
|
<input
|
||||||
<Button isLoading={isLoading} className={"btn-primary"} disabled={text === ""} onClick={handleSubmit}>{t("Submit")}</Button>
|
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>
|
||||||
</div>
|
</dialog>
|
||||||
</dialog>
|
</>
|
||||||
</>
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,14 +1,20 @@
|
|||||||
export default function showToast({message, type}: {message: string, type?: "success" | "error" | "warning" | "info"}) {
|
export default function showToast({
|
||||||
type = type || "info"
|
message,
|
||||||
const div = document.createElement("div")
|
type,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
type?: "success" | "error" | "warning" | "info";
|
||||||
|
}) {
|
||||||
|
type = type || "info";
|
||||||
|
const div = document.createElement("div");
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="toast toast-center">
|
<div class="toast toast-center">
|
||||||
<div class="alert shadow ${type === "success" && "alert-success"} ${type === "error" && "alert-error"} ${type === 'warning' && "alert-warning"} ${type === "info" && "alert-info"}">
|
<div class="alert shadow ${type === "success" && "alert-success"} ${type === "error" && "alert-error"} ${type === "warning" && "alert-warning"} ${type === "info" && "alert-info"}">
|
||||||
<span>${message}</span>
|
<span>${message}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`;
|
||||||
document.body.appendChild(div)
|
document.body.appendChild(div);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
div.remove()
|
div.remove();
|
||||||
}, 3000)
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
@@ -7,41 +7,57 @@ export default function UploadingSideBar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = () => {
|
const listener = () => {
|
||||||
console.log("Uploading tasks changed; show uploading: ", uploadingManager.hasTasks());
|
console.log(
|
||||||
setShowUploading(uploadingManager.hasTasks())
|
"Uploading tasks changed; show uploading: ",
|
||||||
|
uploadingManager.hasTasks(),
|
||||||
|
);
|
||||||
|
setShowUploading(uploadingManager.hasTasks());
|
||||||
};
|
};
|
||||||
|
|
||||||
uploadingManager.addListener(listener)
|
uploadingManager.addListener(listener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
uploadingManager.removeListener(listener)
|
uploadingManager.removeListener(listener);
|
||||||
}
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!showUploading) {
|
if (!showUploading) {
|
||||||
return <></>
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<label htmlFor={"uploading-drawer"} className={"btn btn-square btn-ghost relative btn-accent text-primary"}>
|
<>
|
||||||
<div className={"w-6 h-6 overflow-hidden relative"}>
|
<label
|
||||||
<MdArrowUpward className={"move-up-animation pb-0.5"} size={24} />
|
htmlFor={"uploading-drawer"}
|
||||||
<div className={"absolute border-b-2 w-5 bottom-1 left-0.5"}></div>
|
className={"btn btn-square btn-ghost relative btn-accent text-primary"}
|
||||||
</div>
|
>
|
||||||
</label>
|
<div className={"w-6 h-6 overflow-hidden relative"}>
|
||||||
<div className="drawer w-0">
|
<MdArrowUpward className={"move-up-animation pb-0.5"} size={24} />
|
||||||
<input id="uploading-drawer" type="checkbox" className="drawer-toggle" />
|
<div className={"absolute border-b-2 w-5 bottom-1 left-0.5"}></div>
|
||||||
<div className="drawer-side">
|
</div>
|
||||||
<label htmlFor="uploading-drawer" aria-label="close sidebar" className="drawer-overlay"></label>
|
</label>
|
||||||
<div className="menu bg-base-200 text-base-content h-full w-80 p-4 overflow-y-auto ">
|
<div className="drawer w-0">
|
||||||
<div className={"grid grid-cols-1"}>
|
<input
|
||||||
<h2 className={"text-xl mb-2"}>Uploading</h2>
|
id="uploading-drawer"
|
||||||
<UploadingList />
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UploadingList() {
|
function UploadingList() {
|
||||||
@@ -50,22 +66,22 @@ function UploadingList() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = () => {
|
const listener = () => {
|
||||||
setTasks(uploadingManager.getTasks());
|
setTasks(uploadingManager.getTasks());
|
||||||
}
|
};
|
||||||
|
|
||||||
uploadingManager.addListener(listener)
|
uploadingManager.addListener(listener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
uploadingManager.removeListener(listener)
|
uploadingManager.removeListener(listener);
|
||||||
}
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
{
|
<>
|
||||||
tasks.map((task) => {
|
{tasks.map((task) => {
|
||||||
return <TaskTile key={task.id} task={task} />
|
return <TaskTile key={task.id} task={task} />;
|
||||||
})
|
})}
|
||||||
}
|
</>
|
||||||
</>
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskTile({ task }: { task: UploadingTask }) {
|
function TaskTile({ task }: { task: UploadingTask }) {
|
||||||
@@ -77,51 +93,73 @@ function TaskTile({ task }: { task: UploadingTask }) {
|
|||||||
const listener = () => {
|
const listener = () => {
|
||||||
setProgress(task.progress);
|
setProgress(task.progress);
|
||||||
setError(task.errorMessage);
|
setError(task.errorMessage);
|
||||||
}
|
};
|
||||||
|
|
||||||
task.addListener(listener)
|
task.addListener(listener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
task.removeListener(listener)
|
task.removeListener(listener);
|
||||||
}
|
};
|
||||||
}, [task]);
|
}, [task]);
|
||||||
|
|
||||||
return <div className={"card card-border border-base-300 p-2 my-2 w-full"}>
|
return (
|
||||||
<p className={"p-1 mb-2 w-full break-all line-clamp-2"}>{task.filename}</p>
|
<div className={"card card-border border-base-300 p-2 my-2 w-full"}>
|
||||||
<progress className="progress progress-primary my-2" value={100 * progress} max={100} />
|
<p className={"p-1 mb-2 w-full break-all line-clamp-2"}>
|
||||||
{error && <p className={"text-error p-1"}>{error}</p>}
|
{task.filename}
|
||||||
<div className={"my-2 flex flex-row-reverse"}>
|
</p>
|
||||||
{
|
<progress
|
||||||
error && <button className={"btn h-7 mr-1"} onClick={() => {
|
className="progress progress-primary my-2"
|
||||||
task.start();
|
value={100 * progress}
|
||||||
}}>
|
max={100}
|
||||||
Retry
|
/>
|
||||||
</button>
|
{error && <p className={"text-error p-1"}>{error}</p>}
|
||||||
}
|
<div className={"my-2 flex flex-row-reverse"}>
|
||||||
<button className={"btn btn-error h-7"} onClick={() => {
|
{error && (
|
||||||
const dialog = document.getElementById(`cancel_task_${task.id}`) as HTMLDialogElement;
|
<button
|
||||||
dialog.showModal();
|
className={"btn h-7 mr-1"}
|
||||||
}}>
|
onClick={() => {
|
||||||
Cancel
|
task.start();
|
||||||
</button>
|
}}
|
||||||
</div>
|
>
|
||||||
<dialog id={`cancel_task_${task.id}`} className="modal">
|
Retry
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</dialog>
|
<dialog id={`cancel_task_${task.id}`} className="modal">
|
||||||
</div>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,85 +1,94 @@
|
|||||||
export const i18nData = {
|
export const i18nData = {
|
||||||
"en": {
|
en: {
|
||||||
translation: {
|
translation: {
|
||||||
"My Profile": "My Profile",
|
"My Profile": "My Profile",
|
||||||
"Publish": "Publish",
|
Publish: "Publish",
|
||||||
"Log out": "Log out",
|
"Log out": "Log out",
|
||||||
"Are you sure you want to log out?": "Are you sure you want to log out?",
|
"Are you sure you want to log out?": "Are you sure you want to log out?",
|
||||||
"Cancel": "Cancel",
|
Cancel: "Cancel",
|
||||||
"Confirm": "Confirm",
|
Confirm: "Confirm",
|
||||||
"Search": "Search",
|
Search: "Search",
|
||||||
"Login": "Login",
|
Login: "Login",
|
||||||
"Register": "Register",
|
Register: "Register",
|
||||||
"Username": "Username",
|
Username: "Username",
|
||||||
"Password": "Password",
|
Password: "Password",
|
||||||
"Confirm Password": "Confirm Password",
|
"Confirm Password": "Confirm Password",
|
||||||
"Username and password cannot be empty": "Username and password cannot be empty",
|
"Username and password cannot be empty":
|
||||||
|
"Username and password cannot be empty",
|
||||||
"Passwords do not match": "Passwords do not match",
|
"Passwords do not match": "Passwords do not match",
|
||||||
"Continue": "Continue",
|
Continue: "Continue",
|
||||||
"Don't have an account? Register": "Don't have an account? Register",
|
"Don't have an account? Register": "Don't have an account? Register",
|
||||||
"Already have an account? Login": "Already have an account? Login",
|
"Already have an account? Login": "Already have an account? Login",
|
||||||
"Publish Resource": "Publish Resource",
|
"Publish Resource": "Publish Resource",
|
||||||
"All information can be modified after publishing": "All information can be modified after publishing",
|
"All information can be modified after publishing":
|
||||||
"Title": "Title",
|
"All information can be modified after publishing",
|
||||||
|
Title: "Title",
|
||||||
"Alternative Titles": "Alternative Titles",
|
"Alternative Titles": "Alternative Titles",
|
||||||
"Add Alternative Title": "Add Alternative Title",
|
"Add Alternative Title": "Add Alternative Title",
|
||||||
"Tags": "Tags",
|
Tags: "Tags",
|
||||||
"Description": "Description",
|
Description: "Description",
|
||||||
"Use Markdown format": "Use Markdown format",
|
"Use Markdown format": "Use Markdown format",
|
||||||
"Images": "Images",
|
Images: "Images",
|
||||||
"Images will not be displayed automatically, you need to reference them in the description": "Images will not be displayed automatically, you need to reference them in the description",
|
"Images will not be displayed automatically, you need to reference them in the description":
|
||||||
"Preview": "Preview",
|
"Images will not be displayed automatically, you need to reference them in the description",
|
||||||
"Link": "Link",
|
Preview: "Preview",
|
||||||
"Action": "Action",
|
Link: "Link",
|
||||||
|
Action: "Action",
|
||||||
"Upload Image": "Upload Image",
|
"Upload Image": "Upload Image",
|
||||||
"Error": "Error",
|
Error: "Error",
|
||||||
"Title cannot be empty": "Title cannot be empty",
|
"Title cannot be empty": "Title cannot be empty",
|
||||||
"Alternative title cannot be empty": "Alternative title cannot be empty",
|
"Alternative title cannot be empty": "Alternative title cannot be empty",
|
||||||
"At least one tag required": "At least one tag required",
|
"At least one tag required": "At least one tag required",
|
||||||
"Description cannot be empty": "Description cannot be empty",
|
"Description cannot be empty": "Description cannot be empty",
|
||||||
"Loading": "Loading",
|
Loading: "Loading",
|
||||||
"Enter a search keyword to continue": "Enter a search keyword to continue",
|
"Enter a search keyword to continue":
|
||||||
|
"Enter a search keyword to continue",
|
||||||
"My Info": "My Info",
|
"My Info": "My Info",
|
||||||
"Server": "Server",
|
Server: "Server",
|
||||||
|
|
||||||
// Management page translations
|
// Management page translations
|
||||||
"Manage": "Manage",
|
Manage: "Manage",
|
||||||
"Storage": "Storage",
|
Storage: "Storage",
|
||||||
"Users": "Users",
|
Users: "Users",
|
||||||
"You are not logged in. Please log in to access this page.": "You are not logged in. Please log in to access this page.",
|
"You are not logged in. Please log in to access this page.":
|
||||||
"You are not authorized to access this page.": "You are not authorized to access this page.",
|
"You are not logged in. Please log in to access this page.",
|
||||||
|
"You are not authorized to access this page.":
|
||||||
|
"You are not authorized to access this page.",
|
||||||
|
|
||||||
// Storage management
|
// Storage management
|
||||||
"No storage found. Please create a new storage.": "No storage found. Please create a new storage.",
|
"No storage found. Please create a new storage.":
|
||||||
"Name": "Name",
|
"No storage found. Please create a new storage.",
|
||||||
|
Name: "Name",
|
||||||
"Created At": "Created At",
|
"Created At": "Created At",
|
||||||
"Actions": "Actions",
|
Actions: "Actions",
|
||||||
"Delete Storage": "Delete Storage",
|
"Delete Storage": "Delete Storage",
|
||||||
"Are you sure you want to delete this storage? This action cannot be undone.": "Are you sure you want to delete this storage? This action cannot be undone.",
|
"Are you sure you want to delete this storage? This action cannot be undone.":
|
||||||
"Delete": "Delete",
|
"Are you sure you want to delete this storage? This action cannot be undone.",
|
||||||
|
Delete: "Delete",
|
||||||
"Storage deleted successfully": "Storage deleted successfully",
|
"Storage deleted successfully": "Storage deleted successfully",
|
||||||
"New Storage": "New Storage",
|
"New Storage": "New Storage",
|
||||||
"Type": "Type",
|
Type: "Type",
|
||||||
"Local": "Local",
|
Local: "Local",
|
||||||
"S3": "S3",
|
S3: "S3",
|
||||||
"Path": "Path",
|
Path: "Path",
|
||||||
"Max Size (MB)": "Max Size (MB)",
|
"Max Size (MB)": "Max Size (MB)",
|
||||||
"Endpoint": "Endpoint",
|
Endpoint: "Endpoint",
|
||||||
"Access Key ID": "Access Key ID",
|
"Access Key ID": "Access Key ID",
|
||||||
"Secret Access Key": "Secret Access Key",
|
"Secret Access Key": "Secret Access Key",
|
||||||
"Bucket Name": "Bucket Name",
|
"Bucket Name": "Bucket Name",
|
||||||
"All fields are required": "All fields are required",
|
"All fields are required": "All fields are required",
|
||||||
"Storage created successfully": "Storage created successfully",
|
"Storage created successfully": "Storage created successfully",
|
||||||
"Close": "Close",
|
Close: "Close",
|
||||||
"Submit": "Submit",
|
Submit: "Submit",
|
||||||
|
|
||||||
// User management
|
// User management
|
||||||
"Admin": "Admin",
|
Admin: "Admin",
|
||||||
"Can Upload": "Can Upload",
|
"Can Upload": "Can Upload",
|
||||||
"Yes": "Yes",
|
Yes: "Yes",
|
||||||
"No": "No",
|
No: "No",
|
||||||
"Delete User": "Delete User",
|
"Delete User": "Delete User",
|
||||||
"Are you sure you want to delete user": "Are you sure you want to delete user",
|
"Are you sure you want to delete user":
|
||||||
|
"Are you sure you want to delete user",
|
||||||
"This action cannot be undone.": "This action cannot be undone.",
|
"This action cannot be undone.": "This action cannot be undone.",
|
||||||
"User deleted successfully": "User deleted successfully",
|
"User deleted successfully": "User deleted successfully",
|
||||||
"Set as user": "Set as user",
|
"Set as user": "Set as user",
|
||||||
@@ -88,31 +97,38 @@ export const i18nData = {
|
|||||||
"Grant upload permission": "Grant upload permission",
|
"Grant upload permission": "Grant upload permission",
|
||||||
"User set as admin successfully": "User set as admin successfully",
|
"User set as admin successfully": "User set as admin successfully",
|
||||||
"User set as user successfully": "User set as user successfully",
|
"User set as user successfully": "User set as user successfully",
|
||||||
"User set as upload permission successfully": "User set as upload permission successfully",
|
"User set as upload permission successfully":
|
||||||
"User removed upload permission successfully": "User removed upload permission successfully",
|
"User set as upload permission successfully",
|
||||||
|
"User removed upload permission successfully":
|
||||||
|
"User removed upload permission successfully",
|
||||||
|
|
||||||
// Resource details page
|
// Resource details page
|
||||||
"Resource ID is required": "Resource ID is required",
|
"Resource ID is required": "Resource ID is required",
|
||||||
"Files": "Files",
|
Files: "Files",
|
||||||
"Comments": "Comments",
|
Comments: "Comments",
|
||||||
"Upload": "Upload",
|
Upload: "Upload",
|
||||||
"Create File": "Create File",
|
"Create File": "Create File",
|
||||||
"Please select a file type": "Please select a file type",
|
"Please select a file type": "Please select a file type",
|
||||||
"Please fill in all fields": "Please fill in all fields",
|
"Please fill in all fields": "Please fill in all fields",
|
||||||
"File created successfully": "File created successfully",
|
"File created successfully": "File created successfully",
|
||||||
"Successfully create uploading task.": "Successfully create uploading task.",
|
"Successfully create uploading task.":
|
||||||
|
"Successfully create uploading task.",
|
||||||
"Please select a file and storage": "Please select a file and storage",
|
"Please select a file and storage": "Please select a file and storage",
|
||||||
"Redirect": "Redirect",
|
Redirect: "Redirect",
|
||||||
"User who click the file will be redirected to the URL": "User who click the file will be redirected to the URL",
|
"User who click the file will be redirected to the URL":
|
||||||
|
"User who click the file will be redirected to the URL",
|
||||||
"File Name": "File Name",
|
"File Name": "File Name",
|
||||||
"URL": "URL",
|
URL: "URL",
|
||||||
"Upload a file to server, then the file will be moved to the selected storage.": "Upload a file to server, then the file will be moved to the selected storage.",
|
"Upload a file to server, then the file will be moved to the selected storage.":
|
||||||
|
"Upload a file to server, then the file will be moved to the selected storage.",
|
||||||
"Select Storage": "Select Storage",
|
"Select Storage": "Select Storage",
|
||||||
"Resource Details": "Resource Details",
|
"Resource Details": "Resource Details",
|
||||||
"Delete Resource": "Delete Resource",
|
"Delete Resource": "Delete Resource",
|
||||||
"Are you sure you want to delete the resource": "Are you sure you want to delete the resource",
|
"Are you sure you want to delete the resource":
|
||||||
|
"Are you sure you want to delete the resource",
|
||||||
"Delete File": "Delete File",
|
"Delete File": "Delete File",
|
||||||
"Are you sure you want to delete the file": "Are you sure you want to delete the file",
|
"Are you sure you want to delete the file":
|
||||||
|
"Are you sure you want to delete the file",
|
||||||
|
|
||||||
// New translations
|
// New translations
|
||||||
"Change Avatar": "Change Avatar",
|
"Change Avatar": "Change Avatar",
|
||||||
@@ -120,7 +136,7 @@ export const i18nData = {
|
|||||||
"Change Password": "Change Password",
|
"Change Password": "Change Password",
|
||||||
"New Username": "New Username",
|
"New Username": "New Username",
|
||||||
"Enter new username": "Enter new username",
|
"Enter new username": "Enter new username",
|
||||||
"Save": "Save",
|
Save: "Save",
|
||||||
"Current Password": "Current Password",
|
"Current Password": "Current Password",
|
||||||
"Enter current password": "Enter current password",
|
"Enter current password": "Enter current password",
|
||||||
"New Password": "New Password",
|
"New Password": "New Password",
|
||||||
@@ -135,14 +151,17 @@ export const i18nData = {
|
|||||||
"Update server config successfully": "Update server config successfully",
|
"Update server config successfully": "Update server config successfully",
|
||||||
"Max uploading size (MB)": "Max uploading size (MB)",
|
"Max uploading size (MB)": "Max uploading size (MB)",
|
||||||
"Max file size (MB)": "Max file size (MB)",
|
"Max file size (MB)": "Max file size (MB)",
|
||||||
"Max downloads per day for single IP": "Max downloads per day for single IP",
|
"Max downloads per day for single IP":
|
||||||
|
"Max downloads per day for single IP",
|
||||||
"Allow register": "Allow register",
|
"Allow register": "Allow register",
|
||||||
"Server name": "Server name",
|
"Server name": "Server name",
|
||||||
"Server description": "Server description",
|
"Server description": "Server description",
|
||||||
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile Site Key",
|
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile Site Key",
|
||||||
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile Secret Key",
|
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile Secret Key",
|
||||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.": "If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.",
|
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
|
||||||
"The first image will be used as the cover image": "The first image will be used as the cover image",
|
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.",
|
||||||
|
"The first image will be used as the cover image":
|
||||||
|
"The first image will be used as the cover image",
|
||||||
"Please enter a search keyword": "Please enter a search keyword",
|
"Please enter a search keyword": "Please enter a search keyword",
|
||||||
"Searching...": "Searching...",
|
"Searching...": "Searching...",
|
||||||
"Create Tag": "Create Tag",
|
"Create Tag": "Create Tag",
|
||||||
@@ -153,7 +172,7 @@ export const i18nData = {
|
|||||||
"Tag not found": "Tag not found",
|
"Tag not found": "Tag not found",
|
||||||
"Description is too long": "Description is too long",
|
"Description is too long": "Description is too long",
|
||||||
"Unknown error": "Unknown error",
|
"Unknown error": "Unknown error",
|
||||||
"Edit": "Edit",
|
Edit: "Edit",
|
||||||
"Edit Tag": "Edit Tag",
|
"Edit Tag": "Edit Tag",
|
||||||
"Set the description of the tag.": "Set the description of the tag.",
|
"Set the description of the tag.": "Set the description of the tag.",
|
||||||
"Use markdown format.": "Use markdown format.",
|
"Use markdown format.": "Use markdown format.",
|
||||||
@@ -166,99 +185,109 @@ export const i18nData = {
|
|||||||
"Downloads Ascending": "Downloads Ascending",
|
"Downloads Ascending": "Downloads Ascending",
|
||||||
"Downloads Descending": "Downloads Descending",
|
"Downloads Descending": "Downloads Descending",
|
||||||
"File Url": "File Url",
|
"File Url": "File Url",
|
||||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.": "Provide a file url for the server to download, and the file will be moved to the selected storage.",
|
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
|
||||||
|
"Provide a file url for the server to download, and the file will be moved to the selected storage.",
|
||||||
"Verifying your request": "Verifying your request",
|
"Verifying your request": "Verifying your request",
|
||||||
"Please check your network if the verification takes too long or the captcha does not appear.": "Please check your network if the verification takes too long or the captcha does not appear.",
|
"Please check your network if the verification takes too long or the captcha does not appear.":
|
||||||
"About": "About",
|
"Please check your network if the verification takes too long or the captcha does not appear.",
|
||||||
"Home": "Home",
|
About: "About",
|
||||||
"Other": "Other",
|
Home: "Home",
|
||||||
|
Other: "Other",
|
||||||
"Quick Add": "Quick Add",
|
"Quick Add": "Quick Add",
|
||||||
"Add Tags": "Add Tags",
|
"Add Tags": "Add Tags",
|
||||||
"Input tags separated by separator.": "Input tags separated by separator.",
|
"Input tags separated by separator.":
|
||||||
"If the tag does not exist, it will be created automatically.": "If the tag does not exist, it will be created automatically.",
|
"Input tags separated by separator.",
|
||||||
"Optionally, you can specify a type for the new tags.": "Optionally, you can specify a type for the new tags.",
|
"If the tag does not exist, it will be created automatically.":
|
||||||
|
"If the tag does not exist, it will be created automatically.",
|
||||||
|
"Optionally, you can specify a type for the new tags.":
|
||||||
|
"Optionally, you can specify a type for the new tags.",
|
||||||
"Upload Clipboard Image": "Upload Clipboard Image",
|
"Upload Clipboard Image": "Upload Clipboard Image",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
translation: {
|
translation: {
|
||||||
"My Profile": "我的资料",
|
"My Profile": "我的资料",
|
||||||
"Publish": "发布",
|
Publish: "发布",
|
||||||
"Log out": "退出登录",
|
"Log out": "退出登录",
|
||||||
"Are you sure you want to log out?": "您确定要退出登录吗?",
|
"Are you sure you want to log out?": "您确定要退出登录吗?",
|
||||||
"Cancel": "取消",
|
Cancel: "取消",
|
||||||
"Confirm": "确认",
|
Confirm: "确认",
|
||||||
"Search": "搜索",
|
Search: "搜索",
|
||||||
"Login": "登录",
|
Login: "登录",
|
||||||
"Register": "注册",
|
Register: "注册",
|
||||||
"Username": "用户名",
|
Username: "用户名",
|
||||||
"Password": "密码",
|
Password: "密码",
|
||||||
"Confirm Password": "确认密码",
|
"Confirm Password": "确认密码",
|
||||||
"Username and password cannot be empty": "用户名和密码不能为空",
|
"Username and password cannot be empty": "用户名和密码不能为空",
|
||||||
"Passwords do not match": "两次输入的密码不匹配",
|
"Passwords do not match": "两次输入的密码不匹配",
|
||||||
"Continue": "继续",
|
Continue: "继续",
|
||||||
"Don't have an account? Register": "没有账号?注册",
|
"Don't have an account? Register": "没有账号?注册",
|
||||||
"Already have an account? Login": "已有账号?登录",
|
"Already have an account? Login": "已有账号?登录",
|
||||||
"Publish Resource": "发布资源",
|
"Publish Resource": "发布资源",
|
||||||
"All information can be modified after publishing": "所有的信息均可在发布后修改",
|
"All information can be modified after publishing":
|
||||||
"Title": "标题",
|
"所有的信息均可在发布后修改",
|
||||||
|
Title: "标题",
|
||||||
"Alternative Titles": "其他标题",
|
"Alternative Titles": "其他标题",
|
||||||
"Add Alternative Title": "新增标题",
|
"Add Alternative Title": "新增标题",
|
||||||
"Tags": "标签",
|
Tags: "标签",
|
||||||
"Description": "介绍",
|
Description: "介绍",
|
||||||
"Use Markdown format": "使用Markdown格式",
|
"Use Markdown format": "使用Markdown格式",
|
||||||
"Images": "图片",
|
Images: "图片",
|
||||||
"Images will not be displayed automatically, you need to reference them in the description": "图片不会被自动显示, 你需要在介绍中引用它们",
|
"Images will not be displayed automatically, you need to reference them in the description":
|
||||||
"Preview": "预览",
|
"图片不会被自动显示, 你需要在介绍中引用它们",
|
||||||
"Link": "链接",
|
Preview: "预览",
|
||||||
"Action": "操作",
|
Link: "链接",
|
||||||
|
Action: "操作",
|
||||||
"Upload Image": "上传图片",
|
"Upload Image": "上传图片",
|
||||||
"Error": "错误",
|
Error: "错误",
|
||||||
"Title cannot be empty": "标题不能为空",
|
"Title cannot be empty": "标题不能为空",
|
||||||
"Alternative title cannot be empty": "不能存在空标题",
|
"Alternative title cannot be empty": "不能存在空标题",
|
||||||
"At least one tag required": "至少选择一个标签",
|
"At least one tag required": "至少选择一个标签",
|
||||||
"Description cannot be empty": "介绍不能为空",
|
"Description cannot be empty": "介绍不能为空",
|
||||||
"Loading": "加载中",
|
Loading: "加载中",
|
||||||
"Enter a search keyword to continue": "输入搜索关键词以继续",
|
"Enter a search keyword to continue": "输入搜索关键词以继续",
|
||||||
"My Info": "个人信息",
|
"My Info": "个人信息",
|
||||||
"Server": "服务器",
|
Server: "服务器",
|
||||||
|
|
||||||
// Management page translations
|
// Management page translations
|
||||||
"Manage": "管理",
|
Manage: "管理",
|
||||||
"Storage": "存储",
|
Storage: "存储",
|
||||||
"Users": "用户",
|
Users: "用户",
|
||||||
"You are not logged in. Please log in to access this page.": "您尚未登录。请登录以访问此页面。",
|
"You are not logged in. Please log in to access this page.":
|
||||||
|
"您尚未登录。请登录以访问此页面。",
|
||||||
"You are not authorized to access this page.": "您无权访问此页面。",
|
"You are not authorized to access this page.": "您无权访问此页面。",
|
||||||
|
|
||||||
// Storage management
|
// Storage management
|
||||||
"No storage found. Please create a new storage.": "未找到存储。请创建新的存储。",
|
"No storage found. Please create a new storage.":
|
||||||
"Name": "名称",
|
"未找到存储。请创建新的存储。",
|
||||||
|
Name: "名称",
|
||||||
"Created At": "创建于",
|
"Created At": "创建于",
|
||||||
"Actions": "操作",
|
Actions: "操作",
|
||||||
"Delete Storage": "删除存储",
|
"Delete Storage": "删除存储",
|
||||||
"Are you sure you want to delete this storage? This action cannot be undone.": "您确定要删除此存储吗?此操作不可撤销。",
|
"Are you sure you want to delete this storage? This action cannot be undone.":
|
||||||
"Delete": "删除",
|
"您确定要删除此存储吗?此操作不可撤销。",
|
||||||
|
Delete: "删除",
|
||||||
"Storage deleted successfully": "存储已成功删除",
|
"Storage deleted successfully": "存储已成功删除",
|
||||||
"New Storage": "新建存储",
|
"New Storage": "新建存储",
|
||||||
"Type": "类型",
|
Type: "类型",
|
||||||
"Local": "本地",
|
Local: "本地",
|
||||||
"S3": "S3",
|
S3: "S3",
|
||||||
"Path": "路径",
|
Path: "路径",
|
||||||
"Max Size (MB)": "最大大小 (MB)",
|
"Max Size (MB)": "最大大小 (MB)",
|
||||||
"Endpoint": "终端节点",
|
Endpoint: "终端节点",
|
||||||
"Access Key ID": "访问密钥 ID",
|
"Access Key ID": "访问密钥 ID",
|
||||||
"Secret Access Key": "私有访问密钥",
|
"Secret Access Key": "私有访问密钥",
|
||||||
"Bucket Name": "桶名称",
|
"Bucket Name": "桶名称",
|
||||||
"All fields are required": "所有字段都是必填的",
|
"All fields are required": "所有字段都是必填的",
|
||||||
"Storage created successfully": "存储创建成功",
|
"Storage created successfully": "存储创建成功",
|
||||||
"Close": "关闭",
|
Close: "关闭",
|
||||||
"Submit": "提交",
|
Submit: "提交",
|
||||||
|
|
||||||
// User management
|
// User management
|
||||||
"Admin": "管理员",
|
Admin: "管理员",
|
||||||
"Can Upload": "可上传",
|
"Can Upload": "可上传",
|
||||||
"Yes": "是",
|
Yes: "是",
|
||||||
"No": "否",
|
No: "否",
|
||||||
"Delete User": "删除用户",
|
"Delete User": "删除用户",
|
||||||
"Are you sure you want to delete user": "您确定要删除用户",
|
"Are you sure you want to delete user": "您确定要删除用户",
|
||||||
"This action cannot be undone.": "此操作不可撤销。",
|
"This action cannot be undone.": "此操作不可撤销。",
|
||||||
@@ -274,20 +303,22 @@ export const i18nData = {
|
|||||||
|
|
||||||
// Resource details page
|
// Resource details page
|
||||||
"Resource ID is required": "资源ID是必需的",
|
"Resource ID is required": "资源ID是必需的",
|
||||||
"Files": "文件",
|
Files: "文件",
|
||||||
"Comments": "评论",
|
Comments: "评论",
|
||||||
"Upload": "上传",
|
Upload: "上传",
|
||||||
"Create File": "创建文件",
|
"Create File": "创建文件",
|
||||||
"Please select a file type": "请选择文件类型",
|
"Please select a file type": "请选择文件类型",
|
||||||
"Please fill in all fields": "请填写所有字段",
|
"Please fill in all fields": "请填写所有字段",
|
||||||
"File created successfully": "文件创建成功",
|
"File created successfully": "文件创建成功",
|
||||||
"Successfully create uploading task.": "成功创建上传任务。",
|
"Successfully create uploading task.": "成功创建上传任务。",
|
||||||
"Please select a file and storage": "请选择文件和存储",
|
"Please select a file and storage": "请选择文件和存储",
|
||||||
"Redirect": "重定向",
|
Redirect: "重定向",
|
||||||
"User who click the file will be redirected to the URL": "点击文件的用户将被重定向到URL",
|
"User who click the file will be redirected to the URL":
|
||||||
|
"点击文件的用户将被重定向到URL",
|
||||||
"File Name": "文件名",
|
"File Name": "文件名",
|
||||||
"URL": "URL",
|
URL: "URL",
|
||||||
"Upload a file to server, then the file will be moved to the selected storage.": "将文件上传到服务器,然后文件将被移动到选定的存储中。",
|
"Upload a file to server, then the file will be moved to the selected storage.":
|
||||||
|
"将文件上传到服务器,然后文件将被移动到选定的存储中。",
|
||||||
"Select Storage": "选择存储",
|
"Select Storage": "选择存储",
|
||||||
"Resource Details": "资源详情",
|
"Resource Details": "资源详情",
|
||||||
"Delete Resource": "删除资源",
|
"Delete Resource": "删除资源",
|
||||||
@@ -301,7 +332,7 @@ export const i18nData = {
|
|||||||
"Change Password": "更改密码",
|
"Change Password": "更改密码",
|
||||||
"New Username": "新用户名",
|
"New Username": "新用户名",
|
||||||
"Enter new username": "输入新用户名",
|
"Enter new username": "输入新用户名",
|
||||||
"Save": "保存",
|
Save: "保存",
|
||||||
"Current Password": "当前密码",
|
"Current Password": "当前密码",
|
||||||
"Enter current password": "输入当前密码",
|
"Enter current password": "输入当前密码",
|
||||||
"New Password": "新密码",
|
"New Password": "新密码",
|
||||||
@@ -322,8 +353,10 @@ export const i18nData = {
|
|||||||
"Server description": "服务器描述",
|
"Server description": "服务器描述",
|
||||||
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile 站点密钥",
|
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile 站点密钥",
|
||||||
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密钥",
|
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密钥",
|
||||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.": "如果设置了 Cloudflare Turnstile 密钥,将在注册和下载时启用验证",
|
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
|
||||||
"The first image will be used as the cover image": "第一张图片将用作封面图片",
|
"如果设置了 Cloudflare Turnstile 密钥,将在注册和下载时启用验证",
|
||||||
|
"The first image will be used as the cover image":
|
||||||
|
"第一张图片将用作封面图片",
|
||||||
"Please enter a search keyword": "请输入搜索关键词",
|
"Please enter a search keyword": "请输入搜索关键词",
|
||||||
"Searching...": "搜索中...",
|
"Searching...": "搜索中...",
|
||||||
"Create Tag": "创建标签",
|
"Create Tag": "创建标签",
|
||||||
@@ -334,7 +367,7 @@ export const i18nData = {
|
|||||||
"Tag not found": "标签未找到",
|
"Tag not found": "标签未找到",
|
||||||
"Description is too long": "描述太长",
|
"Description is too long": "描述太长",
|
||||||
"Unknown error": "未知错误",
|
"Unknown error": "未知错误",
|
||||||
"Edit": "编辑",
|
Edit: "编辑",
|
||||||
"Edit Tag": "编辑标签",
|
"Edit Tag": "编辑标签",
|
||||||
"Set the description of the tag.": "设置标签的描述。",
|
"Set the description of the tag.": "设置标签的描述。",
|
||||||
"Use markdown format.": "使用Markdown格式。",
|
"Use markdown format.": "使用Markdown格式。",
|
||||||
@@ -347,99 +380,108 @@ export const i18nData = {
|
|||||||
"Downloads Ascending": "下载量升序",
|
"Downloads Ascending": "下载量升序",
|
||||||
"Downloads Descending": "下载量降序",
|
"Downloads Descending": "下载量降序",
|
||||||
"File Url": "文件链接",
|
"File Url": "文件链接",
|
||||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.": "提供一个文件链接供服务器下载,文件将被移动到选定的存储中。",
|
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
|
||||||
|
"提供一个文件链接供服务器下载,文件将被移动到选定的存储中。",
|
||||||
"Verifying your request": "正在验证您的请求",
|
"Verifying your request": "正在验证您的请求",
|
||||||
"Please check your network if the verification takes too long or the captcha does not appear.": "如果验证时间过长或验证码未出现, 请检查您的网络连接",
|
"Please check your network if the verification takes too long or the captcha does not appear.":
|
||||||
"About": "关于",
|
"如果验证时间过长或验证码未出现, 请检查您的网络连接",
|
||||||
"Home": "首页",
|
About: "关于",
|
||||||
"Other": "其他",
|
Home: "首页",
|
||||||
|
Other: "其他",
|
||||||
"Quick Add": "快速添加",
|
"Quick Add": "快速添加",
|
||||||
"Add Tags": "添加标签",
|
"Add Tags": "添加标签",
|
||||||
"Input tags separated by separator.": "输入标签, 用分隔符分隔。",
|
"Input tags separated by separator.": "输入标签, 用分隔符分隔。",
|
||||||
"If the tag does not exist, it will be created automatically.": "如果标签不存在, 将自动创建。",
|
"If the tag does not exist, it will be created automatically.":
|
||||||
"Optionally, you can specify a type for the new tags.": "您可以选择为新标签指定一个类型。",
|
"如果标签不存在, 将自动创建。",
|
||||||
|
"Optionally, you can specify a type for the new tags.":
|
||||||
|
"您可以选择为新标签指定一个类型。",
|
||||||
"Upload Clipboard Image": "上传剪贴板图片",
|
"Upload Clipboard Image": "上传剪贴板图片",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"zh-TW": {
|
"zh-TW": {
|
||||||
translation: {
|
translation: {
|
||||||
"My Profile": "我的資料",
|
"My Profile": "我的資料",
|
||||||
"Publish": "發布",
|
Publish: "發布",
|
||||||
"Log out": "登出",
|
"Log out": "登出",
|
||||||
"Are you sure you want to log out?": "您確定要登出嗎?",
|
"Are you sure you want to log out?": "您確定要登出嗎?",
|
||||||
"Cancel": "取消",
|
Cancel: "取消",
|
||||||
"Confirm": "確認",
|
Confirm: "確認",
|
||||||
"Search": "搜尋",
|
Search: "搜尋",
|
||||||
"Login": "登入",
|
Login: "登入",
|
||||||
"Register": "註冊",
|
Register: "註冊",
|
||||||
"Username": "用戶名",
|
Username: "用戶名",
|
||||||
"Password": "密碼",
|
Password: "密碼",
|
||||||
"Confirm Password": "確認密碼",
|
"Confirm Password": "確認密碼",
|
||||||
"Username and password cannot be empty": "用戶名和密碼不能為空",
|
"Username and password cannot be empty": "用戶名和密碼不能為空",
|
||||||
"Passwords do not match": "兩次輸入的密碼不匹配",
|
"Passwords do not match": "兩次輸入的密碼不匹配",
|
||||||
"Continue": "繼續",
|
Continue: "繼續",
|
||||||
"Don't have an account? Register": "沒有賬號?註冊",
|
"Don't have an account? Register": "沒有賬號?註冊",
|
||||||
"Already have an account? Login": "已有賬號?登入",
|
"Already have an account? Login": "已有賬號?登入",
|
||||||
"Publish Resource": "發布資源",
|
"Publish Resource": "發布資源",
|
||||||
"All information can be modified after publishing": "所有資訊均可於發布後修改",
|
"All information can be modified after publishing":
|
||||||
"Title": "標題",
|
"所有資訊均可於發布後修改",
|
||||||
|
Title: "標題",
|
||||||
"Alternative Titles": "其他標題",
|
"Alternative Titles": "其他標題",
|
||||||
"Add Alternative Title": "新增標題",
|
"Add Alternative Title": "新增標題",
|
||||||
"Tags": "標籤",
|
Tags: "標籤",
|
||||||
"Description": "介紹",
|
Description: "介紹",
|
||||||
"Use Markdown format": "使用Markdown格式",
|
"Use Markdown format": "使用Markdown格式",
|
||||||
"Images": "圖片",
|
Images: "圖片",
|
||||||
"Images will not be displayed automatically, you need to reference them in the description": "圖片不會自動顯示,需在介紹中引用",
|
"Images will not be displayed automatically, you need to reference them in the description":
|
||||||
"Preview": "預覽",
|
"圖片不會自動顯示,需在介紹中引用",
|
||||||
"Link": "連結",
|
Preview: "預覽",
|
||||||
"Action": "操作",
|
Link: "連結",
|
||||||
|
Action: "操作",
|
||||||
"Upload Image": "上傳圖片",
|
"Upload Image": "上傳圖片",
|
||||||
"Error": "錯誤",
|
Error: "錯誤",
|
||||||
"Title cannot be empty": "標題不能為空",
|
"Title cannot be empty": "標題不能為空",
|
||||||
"Alternative title cannot be empty": "不能有空的標題",
|
"Alternative title cannot be empty": "不能有空的標題",
|
||||||
"At least one tag required": "至少選擇一個標籤",
|
"At least one tag required": "至少選擇一個標籤",
|
||||||
"Description cannot be empty": "介紹不能為空",
|
"Description cannot be empty": "介紹不能為空",
|
||||||
"Loading": "載入中",
|
Loading: "載入中",
|
||||||
"Enter a search keyword to continue": "輸入搜尋關鍵字以繼續",
|
"Enter a search keyword to continue": "輸入搜尋關鍵字以繼續",
|
||||||
"My Info": "個人信息",
|
"My Info": "個人信息",
|
||||||
"Server": "伺服器",
|
Server: "伺服器",
|
||||||
|
|
||||||
// Management page translations
|
// Management page translations
|
||||||
"Manage": "管理",
|
Manage: "管理",
|
||||||
"Storage": "儲存",
|
Storage: "儲存",
|
||||||
"Users": "用戶",
|
Users: "用戶",
|
||||||
"You are not logged in. Please log in to access this page.": "您尚未登入。請登入以訪問此頁面。",
|
"You are not logged in. Please log in to access this page.":
|
||||||
|
"您尚未登入。請登入以訪問此頁面。",
|
||||||
"You are not authorized to access this page.": "您無權訪問此頁面。",
|
"You are not authorized to access this page.": "您無權訪問此頁面。",
|
||||||
|
|
||||||
// Storage management
|
// Storage management
|
||||||
"No storage found. Please create a new storage.": "未找到儲存。請創建新的儲存。",
|
"No storage found. Please create a new storage.":
|
||||||
"Name": "名稱",
|
"未找到儲存。請創建新的儲存。",
|
||||||
|
Name: "名稱",
|
||||||
"Created At": "建立於",
|
"Created At": "建立於",
|
||||||
"Actions": "操作",
|
Actions: "操作",
|
||||||
"Delete Storage": "刪除儲存",
|
"Delete Storage": "刪除儲存",
|
||||||
"Are you sure you want to delete this storage? This action cannot be undone.": "您確定要刪除此儲存嗎?此操作不可撤銷。",
|
"Are you sure you want to delete this storage? This action cannot be undone.":
|
||||||
"Delete": "刪除",
|
"您確定要刪除此儲存嗎?此操作不可撤銷。",
|
||||||
|
Delete: "刪除",
|
||||||
"Storage deleted successfully": "儲存已成功刪除",
|
"Storage deleted successfully": "儲存已成功刪除",
|
||||||
"New Storage": "新建儲存",
|
"New Storage": "新建儲存",
|
||||||
"Type": "類型",
|
Type: "類型",
|
||||||
"Local": "本地",
|
Local: "本地",
|
||||||
"S3": "S3",
|
S3: "S3",
|
||||||
"Path": "路徑",
|
Path: "路徑",
|
||||||
"Max Size (MB)": "最大大小 (MB)",
|
"Max Size (MB)": "最大大小 (MB)",
|
||||||
"Endpoint": "端點",
|
Endpoint: "端點",
|
||||||
"Access Key ID": "訪問密鑰 ID",
|
"Access Key ID": "訪問密鑰 ID",
|
||||||
"Secret Access Key": "私有訪問密鑰",
|
"Secret Access Key": "私有訪問密鑰",
|
||||||
"Bucket Name": "儲存桶名稱",
|
"Bucket Name": "儲存桶名稱",
|
||||||
"All fields are required": "所有欄位都是必填的",
|
"All fields are required": "所有欄位都是必填的",
|
||||||
"Storage created successfully": "儲存創建成功",
|
"Storage created successfully": "儲存創建成功",
|
||||||
"Close": "關閉",
|
Close: "關閉",
|
||||||
"Submit": "提交",
|
Submit: "提交",
|
||||||
|
|
||||||
// User management
|
// User management
|
||||||
"Admin": "管理員",
|
Admin: "管理員",
|
||||||
"Can Upload": "可上傳",
|
"Can Upload": "可上傳",
|
||||||
"Yes": "是",
|
Yes: "是",
|
||||||
"No": "否",
|
No: "否",
|
||||||
"Delete User": "刪除用戶",
|
"Delete User": "刪除用戶",
|
||||||
"Are you sure you want to delete user": "您確定要刪除用戶",
|
"Are you sure you want to delete user": "您確定要刪除用戶",
|
||||||
"This action cannot be undone.": "此操作不可撤銷。",
|
"This action cannot be undone.": "此操作不可撤銷。",
|
||||||
@@ -455,20 +497,22 @@ export const i18nData = {
|
|||||||
|
|
||||||
// Resource details page
|
// Resource details page
|
||||||
"Resource ID is required": "資源ID是必需的",
|
"Resource ID is required": "資源ID是必需的",
|
||||||
"Files": "檔案",
|
Files: "檔案",
|
||||||
"Comments": "評論",
|
Comments: "評論",
|
||||||
"Upload": "上傳",
|
Upload: "上傳",
|
||||||
"Create File": "創建檔案",
|
"Create File": "創建檔案",
|
||||||
"Please select a file type": "請選擇檔案類型",
|
"Please select a file type": "請選擇檔案類型",
|
||||||
"Please fill in all fields": "請填寫所有欄位",
|
"Please fill in all fields": "請填寫所有欄位",
|
||||||
"File created successfully": "檔案創建成功",
|
"File created successfully": "檔案創建成功",
|
||||||
"Successfully create uploading task.": "成功創建上傳任務。",
|
"Successfully create uploading task.": "成功創建上傳任務。",
|
||||||
"Please select a file and storage": "請選擇檔案和儲存",
|
"Please select a file and storage": "請選擇檔案和儲存",
|
||||||
"Redirect": "重定向",
|
Redirect: "重定向",
|
||||||
"User who click the file will be redirected to the URL": "點擊檔案的用戶將被重定向到URL",
|
"User who click the file will be redirected to the URL":
|
||||||
|
"點擊檔案的用戶將被重定向到URL",
|
||||||
"File Name": "檔案名",
|
"File Name": "檔案名",
|
||||||
"URL": "URL",
|
URL: "URL",
|
||||||
"Upload a file to server, then the file will be moved to the selected storage.": "將檔案上傳到伺服器,然後檔案將被移動到選定的儲存中。",
|
"Upload a file to server, then the file will be moved to the selected storage.":
|
||||||
|
"將檔案上傳到伺服器,然後檔案將被移動到選定的儲存中。",
|
||||||
"Select Storage": "選擇儲存",
|
"Select Storage": "選擇儲存",
|
||||||
"Resource Details": "資源詳情",
|
"Resource Details": "資源詳情",
|
||||||
"Delete Resource": "刪除資源",
|
"Delete Resource": "刪除資源",
|
||||||
@@ -482,7 +526,7 @@ export const i18nData = {
|
|||||||
"Change Password": "更改密碼",
|
"Change Password": "更改密碼",
|
||||||
"New Username": "新用戶名",
|
"New Username": "新用戶名",
|
||||||
"Enter new username": "輸入新用戶名",
|
"Enter new username": "輸入新用戶名",
|
||||||
"Save": "儲存",
|
Save: "儲存",
|
||||||
"Current Password": "當前密碼",
|
"Current Password": "當前密碼",
|
||||||
"Enter current password": "輸入當前密碼",
|
"Enter current password": "輸入當前密碼",
|
||||||
"New Password": "新密碼",
|
"New Password": "新密碼",
|
||||||
@@ -503,8 +547,10 @@ export const i18nData = {
|
|||||||
"Server description": "伺服器描述",
|
"Server description": "伺服器描述",
|
||||||
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile 網站密鑰",
|
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile 網站密鑰",
|
||||||
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密鑰",
|
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密鑰",
|
||||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.": "如果設置了 Cloudflare Turnstile 密鑰,將在註冊和下載時啟用驗證",
|
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
|
||||||
"The first image will be used as the cover image": "第一張圖片將用作封面圖片",
|
"如果設置了 Cloudflare Turnstile 密鑰,將在註冊和下載時啟用驗證",
|
||||||
|
"The first image will be used as the cover image":
|
||||||
|
"第一張圖片將用作封面圖片",
|
||||||
"Please enter a search keyword": "請輸入搜尋關鍵字",
|
"Please enter a search keyword": "請輸入搜尋關鍵字",
|
||||||
"Searching...": "搜尋中...",
|
"Searching...": "搜尋中...",
|
||||||
"Create Tag": "創建標籤",
|
"Create Tag": "創建標籤",
|
||||||
@@ -515,7 +561,7 @@ export const i18nData = {
|
|||||||
"Tag not found": "標籤未找到",
|
"Tag not found": "標籤未找到",
|
||||||
"Description is too long": "描述太長",
|
"Description is too long": "描述太長",
|
||||||
"Unknown error": "未知錯誤",
|
"Unknown error": "未知錯誤",
|
||||||
"Edit": "編輯",
|
Edit: "編輯",
|
||||||
"Edit Tag": "編輯標籤",
|
"Edit Tag": "編輯標籤",
|
||||||
"Set the description of the tag.": "設置標籤的描述。",
|
"Set the description of the tag.": "設置標籤的描述。",
|
||||||
"Use markdown format.": "使用Markdown格式。",
|
"Use markdown format.": "使用Markdown格式。",
|
||||||
@@ -528,18 +574,22 @@ export const i18nData = {
|
|||||||
"Downloads Ascending": "下載量升序",
|
"Downloads Ascending": "下載量升序",
|
||||||
"Downloads Descending": "下載量降序",
|
"Downloads Descending": "下載量降序",
|
||||||
"File Url": "檔案連結",
|
"File Url": "檔案連結",
|
||||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.": "提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。",
|
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
|
||||||
|
"提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。",
|
||||||
"Verifying your request": "正在驗證您的請求",
|
"Verifying your request": "正在驗證您的請求",
|
||||||
"Please check your network if the verification takes too long or the captcha does not appear.": "如果驗證時間過長或驗證碼未出現,請檢查您的網絡連接。",
|
"Please check your network if the verification takes too long or the captcha does not appear.":
|
||||||
"About": "關於",
|
"如果驗證時間過長或驗證碼未出現,請檢查您的網絡連接。",
|
||||||
"Home": "首頁",
|
About: "關於",
|
||||||
"Other": "其他",
|
Home: "首頁",
|
||||||
|
Other: "其他",
|
||||||
"Quick Add": "快速添加",
|
"Quick Add": "快速添加",
|
||||||
"Add Tags": "添加標籤",
|
"Add Tags": "添加標籤",
|
||||||
"Input tags separated by separator.": "輸入標籤, 用分隔符分隔。",
|
"Input tags separated by separator.": "輸入標籤, 用分隔符分隔。",
|
||||||
"If the tag does not exist, it will be created automatically.": "如果標籤不存在, 將自動創建。",
|
"If the tag does not exist, it will be created automatically.":
|
||||||
"Optionally, you can specify a type for the new tags.": "您可以選擇為新標籤指定一個類型。",
|
"如果標籤不存在, 將自動創建。",
|
||||||
|
"Optionally, you can specify a type for the new tags.":
|
||||||
|
"您可以選擇為新標籤指定一個類型。",
|
||||||
"Upload Clipboard Image": "上傳剪貼板圖片",
|
"Upload Clipboard Image": "上傳剪貼板圖片",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
@@ -2,131 +2,131 @@
|
|||||||
@plugin "daisyui";
|
@plugin "daisyui";
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "light";
|
name: "light";
|
||||||
default: true;
|
default: true;
|
||||||
prefersdark: false;
|
prefersdark: false;
|
||||||
color-scheme: "light";
|
color-scheme: "light";
|
||||||
--color-base-100: oklch(99% 0.014 343.198);
|
--color-base-100: oklch(99% 0.014 343.198);
|
||||||
--color-base-200: oklch(97% 0.028 342.258);
|
--color-base-200: oklch(97% 0.028 342.258);
|
||||||
--color-base-300: oklch(84% 0.061 343.231);
|
--color-base-300: oklch(84% 0.061 343.231);
|
||||||
--color-base-content: oklch(0% 0 0);
|
--color-base-content: oklch(0% 0 0);
|
||||||
--color-primary: oklch(65% 0.241 354.308);
|
--color-primary: oklch(65% 0.241 354.308);
|
||||||
--color-primary-content: oklch(100% 0 0);
|
--color-primary-content: oklch(100% 0 0);
|
||||||
--color-secondary: oklch(62% 0.265 303.9);
|
--color-secondary: oklch(62% 0.265 303.9);
|
||||||
--color-secondary-content: oklch(97% 0.014 308.299);
|
--color-secondary-content: oklch(97% 0.014 308.299);
|
||||||
--color-accent: oklch(0.759 0.124 274.458);
|
--color-accent: oklch(0.759 0.124 274.458);
|
||||||
--color-accent-content: oklch(20% 0.09 240.876);
|
--color-accent-content: oklch(20% 0.09 240.876);
|
||||||
--color-neutral: oklch(40% 0.153 2.432);
|
--color-neutral: oklch(40% 0.153 2.432);
|
||||||
--color-neutral-content: oklch(89% 0.061 343.231);
|
--color-neutral-content: oklch(89% 0.061 343.231);
|
||||||
--color-info: oklch(75% 0.105 251.813);
|
--color-info: oklch(75% 0.105 251.813);
|
||||||
--color-info-content: oklch(44% 0.11 240.79);
|
--color-info-content: oklch(44% 0.11 240.79);
|
||||||
--color-success: oklch(70% 0.14 182.503);
|
--color-success: oklch(70% 0.14 182.503);
|
||||||
--color-success-content: oklch(43% 0.095 166.913);
|
--color-success-content: oklch(43% 0.095 166.913);
|
||||||
--color-warning: oklch(75% 0.183 55.934);
|
--color-warning: oklch(75% 0.183 55.934);
|
||||||
--color-warning-content: oklch(26% 0.079 36.259);
|
--color-warning-content: oklch(26% 0.079 36.259);
|
||||||
--color-error: oklch(63% 0.237 25.331);
|
--color-error: oklch(63% 0.237 25.331);
|
||||||
--color-error-content: oklch(97% 0.013 17.38);
|
--color-error-content: oklch(97% 0.013 17.38);
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 1rem;
|
||||||
--radius-field: 1.5rem;
|
--radius-field: 1.5rem;
|
||||||
--radius-box: 1rem;
|
--radius-box: 1rem;
|
||||||
--size-selector: 0.25rem;
|
--size-selector: 0.25rem;
|
||||||
--size-field: 0.25rem;
|
--size-field: 0.25rem;
|
||||||
--border: 1px;
|
--border: 1px;
|
||||||
--depth: 1;
|
--depth: 1;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "dark";
|
name: "dark";
|
||||||
default: false;
|
default: false;
|
||||||
prefersdark: true;
|
prefersdark: true;
|
||||||
color-scheme: "dark";
|
color-scheme: "dark";
|
||||||
--color-base-100: oklch(28% 0.022 277.508);
|
--color-base-100: oklch(28% 0.022 277.508);
|
||||||
--color-base-200: oklch(24% 0.02 277.508);
|
--color-base-200: oklch(24% 0.02 277.508);
|
||||||
--color-base-300: oklch(40% 0.153 2.432);
|
--color-base-300: oklch(40% 0.153 2.432);
|
||||||
--color-base-content: oklch(97.747% 0.007 106.545);
|
--color-base-content: oklch(97.747% 0.007 106.545);
|
||||||
--color-primary: oklch(75.461% 0.183 346.812);
|
--color-primary: oklch(75.461% 0.183 346.812);
|
||||||
--color-primary-content: oklch(15.092% 0.036 346.812);
|
--color-primary-content: oklch(15.092% 0.036 346.812);
|
||||||
--color-secondary: oklch(74.202% 0.148 301.883);
|
--color-secondary: oklch(74.202% 0.148 301.883);
|
||||||
--color-secondary-content: oklch(14.84% 0.029 301.883);
|
--color-secondary-content: oklch(14.84% 0.029 301.883);
|
||||||
--color-accent: oklch(83.392% 0.124 66.558);
|
--color-accent: oklch(83.392% 0.124 66.558);
|
||||||
--color-accent-content: oklch(16.678% 0.024 66.558);
|
--color-accent-content: oklch(16.678% 0.024 66.558);
|
||||||
--color-neutral: oklch(39.445% 0.032 275.524);
|
--color-neutral: oklch(39.445% 0.032 275.524);
|
||||||
--color-neutral-content: oklch(87.889% 0.006 275.524);
|
--color-neutral-content: oklch(87.889% 0.006 275.524);
|
||||||
--color-info: oklch(88.263% 0.093 212.846);
|
--color-info: oklch(88.263% 0.093 212.846);
|
||||||
--color-info-content: oklch(17.652% 0.018 212.846);
|
--color-info-content: oklch(17.652% 0.018 212.846);
|
||||||
--color-success: oklch(87.099% 0.219 148.024);
|
--color-success: oklch(87.099% 0.219 148.024);
|
||||||
--color-success-content: oklch(17.419% 0.043 148.024);
|
--color-success-content: oklch(17.419% 0.043 148.024);
|
||||||
--color-warning: oklch(95.533% 0.134 112.757);
|
--color-warning: oklch(95.533% 0.134 112.757);
|
||||||
--color-warning-content: oklch(19.106% 0.026 112.757);
|
--color-warning-content: oklch(19.106% 0.026 112.757);
|
||||||
--color-error: oklch(68.22% 0.206 24.43);
|
--color-error: oklch(68.22% 0.206 24.43);
|
||||||
--color-error-content: oklch(13.644% 0.041 24.43);
|
--color-error-content: oklch(13.644% 0.041 24.43);
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 1rem;
|
||||||
--radius-field: 1.5rem;
|
--radius-field: 1.5rem;
|
||||||
--radius-box: 1rem;
|
--radius-box: 1rem;
|
||||||
--size-selector: 0.25rem;
|
--size-selector: 0.25rem;
|
||||||
--size-field: 0.25rem;
|
--size-field: 0.25rem;
|
||||||
--border: 1px;
|
--border: 1px;
|
||||||
--depth: 1;
|
--depth: 1;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes appearance-in {
|
@keyframes appearance-in {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateZ(0) scale(0.95);
|
transform: translateZ(0) scale(0.95);
|
||||||
}
|
}
|
||||||
60% {
|
60% {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
webkit-font-smoothing: antialiased;
|
webkit-font-smoothing: antialiased;
|
||||||
transform: translateZ(0) scale(1.05);
|
transform: translateZ(0) scale(1.05);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateZ(0) scale(1);
|
transform: translateZ(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-appearance-in {
|
.animate-appearance-in {
|
||||||
animation: appearance-in 250ms ease-out normal both;
|
animation: appearance-in 250ms ease-out normal both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.move-up-animation {
|
.move-up-animation {
|
||||||
animation: moveUpAndDown 2s infinite;
|
animation: moveUpAndDown 2s infinite;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes moveUpAndDown {
|
@keyframes moveUpAndDown {
|
||||||
0% {
|
0% {
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
top: -100%;
|
top: -100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-md {
|
.text-md {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import App from "./app.tsx";
|
import App from "./app.tsx";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import {initReactI18next} from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import {i18nData} from "./i18n.ts";
|
import { i18nData } from "./i18n.ts";
|
||||||
import AppContext from "./components/AppContext.tsx";
|
import AppContext from "./components/AppContext.tsx";
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
@@ -16,14 +16,15 @@ i18n
|
|||||||
debug: true,
|
debug: true,
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false
|
escapeValue: false,
|
||||||
}
|
},
|
||||||
}).then(() => {
|
})
|
||||||
createRoot(document.getElementById('root')!).render(
|
.then(() => {
|
||||||
<StrictMode>
|
createRoot(document.getElementById("root")!).render(
|
||||||
<AppContext>
|
<StrictMode>
|
||||||
<App/>
|
<AppContext>
|
||||||
</AppContext>
|
<App />
|
||||||
</StrictMode>,
|
</AppContext>
|
||||||
)
|
</StrictMode>,
|
||||||
})
|
);
|
||||||
|
});
|
||||||
|
@@ -1,110 +1,111 @@
|
|||||||
article {
|
article {
|
||||||
& {
|
& {
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
margin: 24px 0 12px;
|
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;
|
ol {
|
||||||
font-weight: bold;
|
list-style-type: decimal;
|
||||||
padding: 12px 0;
|
margin: 0 0 16px 20px;
|
||||||
margin: 16px 0 8px;
|
padding: 0;
|
||||||
|
li {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
h3 {
|
}
|
||||||
font-size: 16px;
|
blockquote {
|
||||||
font-weight: bold;
|
font-size: 14px;
|
||||||
padding: 6px 0;
|
line-height: 1.5;
|
||||||
margin: 12px 0 4px;
|
margin: 0 0 16px;
|
||||||
}
|
padding: 8px;
|
||||||
h4 {
|
border-left: 4px solid var(--color-base-300);
|
||||||
font-size: 14px;
|
background-color: var(--color-base-200);
|
||||||
font-weight: bold;
|
}
|
||||||
padding: 6px 0;
|
hr {
|
||||||
margin: 12px 0 4px;
|
border: 0;
|
||||||
}
|
border-top: 1px solid var(--color-base-300);
|
||||||
h5 {
|
margin: 16px 0;
|
||||||
font-size: 12px;
|
}
|
||||||
font-weight: bold;
|
a {
|
||||||
padding: 4px 0;
|
color: var(--color-primary);
|
||||||
}
|
text-decoration: none;
|
||||||
h6 {
|
&:hover {
|
||||||
font-size: 10px;
|
text-decoration: underline;
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
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 {
|
a.no-underline {
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
&:hover {
|
}
|
||||||
text-decoration: none;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,11 @@ class Listenable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeListener(listener: () => void) {
|
removeListener(listener: () => void) {
|
||||||
this.listeners = this.listeners.filter(l => l !== listener);
|
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners() {
|
notifyListeners() {
|
||||||
this.listeners.forEach(listener => listener());
|
this.listeners.forEach((listener) => listener());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export class UploadingTask extends Listenable {
|
|||||||
uploadingBlocks: number[] = [];
|
uploadingBlocks: number[] = [];
|
||||||
finishedBlocksCount: number = 0;
|
finishedBlocksCount: number = 0;
|
||||||
|
|
||||||
onFinished: (() => void);
|
onFinished: () => void;
|
||||||
|
|
||||||
get filename() {
|
get filename() {
|
||||||
return this.file.name;
|
return this.file.name;
|
||||||
@@ -49,7 +49,13 @@ export class UploadingTask extends Listenable {
|
|||||||
return this.finishedBlocksCount / this.blocks.length;
|
return this.finishedBlocksCount / this.blocks.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(id: number, file: File, blocksCount: number, blockSize: number, onFinished: () => void) {
|
constructor(
|
||||||
|
id: number,
|
||||||
|
file: File,
|
||||||
|
blocksCount: number,
|
||||||
|
blockSize: number,
|
||||||
|
onFinished: () => void,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.file = file;
|
this.file = file;
|
||||||
@@ -117,7 +123,7 @@ export class UploadingTask extends Listenable {
|
|||||||
}
|
}
|
||||||
this.blocks[index] = true;
|
this.blocks[index] = true;
|
||||||
this.finishedBlocksCount++;
|
this.finishedBlocksCount++;
|
||||||
this.uploadingBlocks = this.uploadingBlocks.filter(i => i !== index);
|
this.uploadingBlocks = this.uploadingBlocks.filter((i) => i !== index);
|
||||||
index++;
|
index++;
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -133,15 +139,14 @@ export class UploadingTask extends Listenable {
|
|||||||
this.upload(),
|
this.upload(),
|
||||||
this.upload(),
|
this.upload(),
|
||||||
this.upload(),
|
this.upload(),
|
||||||
])
|
]);
|
||||||
if (this.status !== UploadingStatus.UPLOADING) {
|
if (this.status !== UploadingStatus.UPLOADING) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let md5 = "";
|
let md5 = "";
|
||||||
try {
|
try {
|
||||||
md5 = await this.calculateMd5(this.file);
|
md5 = await this.calculateMd5(this.file);
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
this.status = UploadingStatus.ERROR;
|
this.status = UploadingStatus.ERROR;
|
||||||
this.errorMessage = "Failed to calculate md5";
|
this.errorMessage = "Failed to calculate md5";
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
@@ -180,40 +185,55 @@ class UploadingManager extends Listenable {
|
|||||||
this.tasks[0].removeListener(this.onTaskStatusChanged);
|
this.tasks[0].removeListener(this.onTaskStatusChanged);
|
||||||
this.tasks.shift();
|
this.tasks.shift();
|
||||||
this.onTaskStatusChanged();
|
this.onTaskStatusChanged();
|
||||||
} else if (this.tasks[0].status === UploadingStatus.ERROR && this.tasks[0].errorMessage === "Cancelled") {
|
} else if (
|
||||||
|
this.tasks[0].status === UploadingStatus.ERROR &&
|
||||||
|
this.tasks[0].errorMessage === "Cancelled"
|
||||||
|
) {
|
||||||
this.tasks[0].removeListener(this.onTaskStatusChanged);
|
this.tasks[0].removeListener(this.onTaskStatusChanged);
|
||||||
this.tasks.shift();
|
this.tasks.shift();
|
||||||
this.onTaskStatusChanged();
|
this.onTaskStatusChanged();
|
||||||
}
|
}
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
}
|
};
|
||||||
|
|
||||||
async addTask(file: File, resourceID: number, storageID: number, description: string, onFinished: () => void): Promise<Response<void>> {
|
async addTask(
|
||||||
|
file: File,
|
||||||
|
resourceID: number,
|
||||||
|
storageID: number,
|
||||||
|
description: string,
|
||||||
|
onFinished: () => void,
|
||||||
|
): Promise<Response<void>> {
|
||||||
const res = await network.initFileUpload(
|
const res = await network.initFileUpload(
|
||||||
file.name,
|
file.name,
|
||||||
description,
|
description,
|
||||||
file.size,
|
file.size,
|
||||||
resourceID,
|
resourceID,
|
||||||
storageID,
|
storageID,
|
||||||
)
|
);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: res.message,
|
message: res.message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const task = new UploadingTask(res.data!.id, file, res.data!.blocksCount, res.data!.blockSize, onFinished);
|
const task = new UploadingTask(
|
||||||
|
res.data!.id,
|
||||||
|
file,
|
||||||
|
res.data!.blocksCount,
|
||||||
|
res.data!.blockSize,
|
||||||
|
onFinished,
|
||||||
|
);
|
||||||
task.addListener(this.onTaskStatusChanged);
|
task.addListener(this.onTaskStatusChanged);
|
||||||
this.tasks.push(task);
|
this.tasks.push(task);
|
||||||
this.onTaskStatusChanged();
|
this.onTaskStatusChanged();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "ok",
|
message: "ok",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getTasks() {
|
getTasks() {
|
||||||
return Array.from(this.tasks)
|
return Array.from(this.tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasTasks() {
|
hasTasks() {
|
||||||
@@ -228,4 +248,4 @@ window.addEventListener("beforeunload", () => {
|
|||||||
return "Uploading files, are you sure you want to leave?";
|
return "Uploading files, are you sure you want to leave?";
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
});
|
||||||
|
@@ -1,38 +1,55 @@
|
|||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import {app} from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import {ReactElement, ReactNode} from "react";
|
import { ReactElement, ReactNode } from "react";
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
return <article className={"p-4"}>
|
return (
|
||||||
<Markdown components={{
|
<article className={"p-4"}>
|
||||||
"a": ({node, ...props}) => {
|
<Markdown
|
||||||
const href = props.href as string
|
components={{
|
||||||
// @ts-ignore
|
a: ({ node, ...props }) => {
|
||||||
if (props.children?.length === 2) {
|
const href = props.href as string;
|
||||||
// @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
|
// @ts-ignore
|
||||||
if (img.type === "img") {
|
if (props.children?.length === 2) {
|
||||||
return <a className={"inline-block card card-border border-base-300 no-underline bg-base-200 hover:shadow transition-shadow"} target={"_blank"} href={href}>
|
// @ts-ignore
|
||||||
<figure className={"max-h-72 max-w-96"}>
|
const first = props.children[0] as ReactNode;
|
||||||
{img}
|
// @ts-ignore
|
||||||
</figure>
|
const second = props.children[1] as ReactNode;
|
||||||
<div className={"card-body text-base-content text-lg"}>
|
|
||||||
{second}
|
if (
|
||||||
</div>
|
typeof first === "object" &&
|
||||||
</a>
|
(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"}>
|
||||||
return <a href={href} target={"_blank"}>{props.children}</a>
|
{props.children}
|
||||||
}
|
</a>
|
||||||
}}>
|
);
|
||||||
{app.siteInfo}
|
},
|
||||||
</Markdown>
|
}}
|
||||||
</article>
|
>
|
||||||
}
|
{app.siteInfo}
|
||||||
|
</Markdown>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,263 +1,330 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {MdAdd, MdClose, MdDelete, MdOutlineInfo} from "react-icons/md";
|
import { MdAdd, MdClose, MdDelete, MdOutlineInfo } from "react-icons/md";
|
||||||
import { Tag } from "../network/models.ts";
|
import { Tag } from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import {useNavigate, useParams} from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import showToast from "../components/toast.ts";
|
import showToast from "../components/toast.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import Loading from "../components/loading.tsx";
|
import Loading from "../components/loading.tsx";
|
||||||
import TagInput, {QuickAddTagDialog} from "../components/tag_input.tsx";
|
import TagInput, { QuickAddTagDialog } from "../components/tag_input.tsx";
|
||||||
import {ImageDrapArea, SelectAndUploadImageButton, UploadClipboardImageButton} from "../components/image_selector.tsx";
|
import {
|
||||||
|
ImageDrapArea,
|
||||||
|
SelectAndUploadImageButton,
|
||||||
|
UploadClipboardImageButton,
|
||||||
|
} from "../components/image_selector.tsx";
|
||||||
|
|
||||||
export default function EditResourcePage() {
|
export default function EditResourcePage() {
|
||||||
const [title, setTitle] = useState<string>("")
|
const [title, setTitle] = useState<string>("");
|
||||||
const [altTitles, setAltTitles] = useState<string[]>([])
|
const [altTitles, setAltTitles] = useState<string[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([])
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [article, setArticle] = useState<string>("")
|
const [article, setArticle] = useState<string>("");
|
||||||
const [images, setImages] = useState<number[]>([])
|
const [images, setImages] = useState<number[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setSubmitting] = useState(false)
|
const [isSubmitting, setSubmitting] = useState(false);
|
||||||
const [isLoading, setLoading] = useState(true)
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("Edit Resource");
|
document.title = t("Edit Resource");
|
||||||
}, [t])
|
}, [t]);
|
||||||
|
|
||||||
const {rid} = useParams()
|
const { rid } = useParams();
|
||||||
const id = parseInt(rid || "")
|
const id = parseInt(rid || "");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
network.getResourceDetails(id).then((res) => {
|
network.getResourceDetails(id).then((res) => {
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
const data = res.data!
|
const data = res.data!;
|
||||||
setTitle(data.title)
|
setTitle(data.title);
|
||||||
setAltTitles(data.alternativeTitles)
|
setAltTitles(data.alternativeTitles);
|
||||||
setTags(data.tags)
|
setTags(data.tags);
|
||||||
setArticle(data.article)
|
setArticle(data.article);
|
||||||
setImages(data.images.map(i => i.id))
|
setImages(data.images.map((i) => i.id));
|
||||||
setLoading(false)
|
setLoading(false);
|
||||||
} else {
|
} else {
|
||||||
showToast({ message: t("Failed to load resource"), type: "error" })
|
showToast({ message: t("Failed to load resource"), type: "error" });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, [id, t]);
|
}, [id, t]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (!title) {
|
if (!title) {
|
||||||
setError(t("Title cannot be empty"))
|
setError(t("Title cannot be empty"));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < altTitles.length; i++) {
|
for (let i = 0; i < altTitles.length; i++) {
|
||||||
if (!altTitles[i]) {
|
if (!altTitles[i]) {
|
||||||
setError(t("Alternative title cannot be empty"))
|
setError(t("Alternative title cannot be empty"));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!tags || tags.length === 0) {
|
if (!tags || tags.length === 0) {
|
||||||
setError(t("At least one tag required"))
|
setError(t("At least one tag required"));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (!article) {
|
if (!article) {
|
||||||
setError(t("Description cannot be empty"))
|
setError(t("Description cannot be empty"));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true);
|
||||||
const res = await network.editResource(id, {
|
const res = await network.editResource(id, {
|
||||||
title: title,
|
title: title,
|
||||||
alternative_titles: altTitles,
|
alternative_titles: altTitles,
|
||||||
tags: tags.map((tag) => tag.id),
|
tags: tags.map((tag) => tag.id),
|
||||||
article: article,
|
article: article,
|
||||||
images: images,
|
images: images,
|
||||||
})
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setSubmitting(false)
|
setSubmitting(false);
|
||||||
navigate("/resources/" + id.toString(), { replace: true })
|
navigate("/resources/" + id.toString(), { replace: true });
|
||||||
} else {
|
} else {
|
||||||
setSubmitting(false)
|
setSubmitting(false);
|
||||||
setError(res.message)
|
setError(res.message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (isNaN(id)) {
|
if (isNaN(id)) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("Invalid resource ID")} />
|
return <ErrorAlert className={"m-4"} message={t("Invalid resource ID")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.user) {
|
if (!app.user) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
return (
|
||||||
}
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
if (isLoading) {
|
message={t("You are not logged in. Please log in to access this page.")}
|
||||||
return <Loading/>
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ImageDrapArea onUploaded={(images) => {
|
if (isLoading) {
|
||||||
setImages((prev) => ([...prev, ...images]));
|
return <Loading />;
|
||||||
}}>
|
}
|
||||||
<div className={"p-4"}>
|
|
||||||
<h1 className={"text-2xl font-bold my-4"}>{t("Edit Resource")}</h1>
|
return (
|
||||||
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
<ImageDrapArea
|
||||||
<MdOutlineInfo size={24} />
|
onUploaded={(images) => {
|
||||||
<span>{t("All information can be modified after publishing")}</span>
|
setImages((prev) => [...prev, ...images]);
|
||||||
</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={"p-4"}>
|
||||||
<div className={"h-4"}></div>
|
<h1 className={"text-2xl font-bold my-4"}>{t("Edit Resource")}</h1>
|
||||||
<p className={"my-1"}>{t("Alternative Titles")}</p>
|
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
||||||
{
|
<MdOutlineInfo size={24} />
|
||||||
altTitles.map((title, index) => {
|
<span>{t("All information can be modified after publishing")}</span>
|
||||||
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>
|
<p className={"my-1"}>{t("Title")}</p>
|
||||||
<div className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}>
|
<input
|
||||||
<table className={"table"}>
|
type="text"
|
||||||
<thead>
|
className="input w-full"
|
||||||
<tr>
|
value={title}
|
||||||
<td>{t("Preview")}</td>
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
<td>{t("Link")}</td>
|
/>
|
||||||
<td>{t("Action")}</td>
|
<div className={"h-4"}></div>
|
||||||
</tr>
|
<p className={"my-1"}>{t("Alternative Titles")}</p>
|
||||||
</thead>
|
{altTitles.map((title, index) => {
|
||||||
<tbody>
|
return (
|
||||||
{
|
<div key={index} className={"flex items-center my-2"}>
|
||||||
images.map((image, index) => {
|
<input
|
||||||
return <tr key={index} className={"hover"}>
|
type="text"
|
||||||
<td>
|
className="input w-full"
|
||||||
<img src={network.getImageUrl(image)} className={"w-16 h-16 object-cover card"} alt={"image"} />
|
value={title}
|
||||||
</td>
|
onChange={(e) => {
|
||||||
<td>
|
const newAltTitles = [...altTitles];
|
||||||
{network.getImageUrl(image)}
|
newAltTitles[index] = e.target.value;
|
||||||
</td>
|
setAltTitles(newAltTitles);
|
||||||
<td>
|
}}
|
||||||
<button className={"btn btn-square"} type={"button"} onClick={() => {
|
/>
|
||||||
const id = images[index]
|
<button
|
||||||
const newImages = [...images]
|
className={"btn btn-square btn-error ml-2"}
|
||||||
newImages.splice(index, 1)
|
type={"button"}
|
||||||
setImages(newImages)
|
onClick={() => {
|
||||||
network.deleteImage(id)
|
const newAltTitles = [...altTitles];
|
||||||
}}>
|
newAltTitles.splice(index, 1);
|
||||||
<MdDelete size={24} />
|
setAltTitles(newAltTitles);
|
||||||
</button>
|
}}
|
||||||
</td>
|
>
|
||||||
</tr>
|
<MdDelete size={24} />
|
||||||
})
|
</button>
|
||||||
}
|
</div>
|
||||||
</tbody>
|
);
|
||||||
</table>
|
})}
|
||||||
</div>
|
<button
|
||||||
<div className={"flex"}>
|
className={"btn my-2"}
|
||||||
<SelectAndUploadImageButton onUploaded={(images) => {
|
type={"button"}
|
||||||
setImages((prev) => ([...prev, ...images]));
|
onClick={() => {
|
||||||
}}/>
|
setAltTitles([...altTitles, ""]);
|
||||||
<span className={"w-4"}></span>
|
}}
|
||||||
<UploadClipboardImageButton onUploaded={(images) => {
|
>
|
||||||
setImages((prev) => ([...prev, ...images]));
|
<MdAdd />
|
||||||
}}/>
|
{t("Add Alternative Title")}
|
||||||
</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>
|
</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>
|
||||||
</div>
|
</ImageDrapArea>
|
||||||
</ImageDrapArea>
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,26 +1,26 @@
|
|||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ResourcesView from "../components/resources_view.tsx";
|
import ResourcesView from "../components/resources_view.tsx";
|
||||||
import {network} from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import {RSort} from "../network/models.ts";
|
import { RSort } from "../network/models.ts";
|
||||||
import {useTranslation} from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {useAppContext} from "../components/AppContext.tsx";
|
import { useAppContext } from "../components/AppContext.tsx";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = app.appName;
|
document.title = app.appName;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const {t} = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const appContext = useAppContext()
|
const appContext = useAppContext();
|
||||||
|
|
||||||
const [order, setOrder] = useState(() => {
|
const [order, setOrder] = useState(() => {
|
||||||
if (appContext && appContext.get("home_page_order") !== undefined) {
|
if (appContext && appContext.get("home_page_order") !== undefined) {
|
||||||
return appContext.get("home_page_order");
|
return appContext.get("home_page_order");
|
||||||
}
|
}
|
||||||
return RSort.TimeDesc;
|
return RSort.TimeDesc;
|
||||||
})
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appContext && order !== RSort.TimeDesc) {
|
if (appContext && order !== RSort.TimeDesc) {
|
||||||
@@ -28,37 +28,43 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}, [appContext, order]);
|
}, [appContext, order]);
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<div className={"flex p-4 items-center"}>
|
<>
|
||||||
<select value={order} className="select w-52 select-info" onInput={(e) => {
|
<div className={"flex p-4 items-center"}>
|
||||||
const value = e.currentTarget.value;
|
<select
|
||||||
if (value === "0") {
|
value={order}
|
||||||
setOrder(RSort.TimeAsc);
|
className="select w-52 select-info"
|
||||||
} else if (value === "1") {
|
onInput={(e) => {
|
||||||
setOrder(RSort.TimeDesc);
|
const value = e.currentTarget.value;
|
||||||
} else if (value === "2") {
|
if (value === "0") {
|
||||||
setOrder(RSort.ViewsAsc);
|
setOrder(RSort.TimeAsc);
|
||||||
} else if (value === "3") {
|
} else if (value === "1") {
|
||||||
setOrder(RSort.ViewsDesc);
|
setOrder(RSort.TimeDesc);
|
||||||
} else if (value === "4") {
|
} else if (value === "2") {
|
||||||
setOrder(RSort.DownloadsAsc);
|
setOrder(RSort.ViewsAsc);
|
||||||
} else if (value === "5") {
|
} else if (value === "3") {
|
||||||
setOrder(RSort.DownloadsDesc);
|
setOrder(RSort.ViewsDesc);
|
||||||
}
|
} else if (value === "4") {
|
||||||
}}>
|
setOrder(RSort.DownloadsAsc);
|
||||||
<option disabled>{t("Select a Order")}</option>
|
} else if (value === "5") {
|
||||||
<option value="0">{t("Time Ascending")}</option>
|
setOrder(RSort.DownloadsDesc);
|
||||||
<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 disabled>{t("Select a Order")}</option>
|
||||||
<option value="5">{t("Downloads Descending")}</option>
|
<option value="0">{t("Time Ascending")}</option>
|
||||||
</select>
|
<option value="1">{t("Time Descending")}</option>
|
||||||
</div>
|
<option value="2">{t("Views Ascending")}</option>
|
||||||
<ResourcesView
|
<option value="3">{t("Views Descending")}</option>
|
||||||
key={`home_page_${order}`}
|
<option value="4">{t("Downloads Ascending")}</option>
|
||||||
storageKey={`home_page_${order}`}
|
<option value="5">{t("Downloads Descending")}</option>
|
||||||
loader={(page) => network.getResources(page, order)}
|
</select>
|
||||||
/>
|
</div>
|
||||||
</>
|
<ResourcesView
|
||||||
}
|
key={`home_page_${order}`}
|
||||||
|
storageKey={`home_page_${order}`}
|
||||||
|
loader={(page) => network.getResources(page, order)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
import {FormEvent, useEffect, useState} from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import {network} from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import {app} from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import {useNavigate} from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import {useTranslation} from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const {t} = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isLoading, setLoading] = useState(false);
|
const [isLoading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
@@ -25,7 +25,7 @@ export default function LoginPage() {
|
|||||||
app.user = res.data!;
|
app.user = res.data!;
|
||||||
app.token = res.data!.token;
|
app.token = res.data!.token;
|
||||||
app.saveData();
|
app.saveData();
|
||||||
navigate("/", {replace: true});
|
navigate("/", { replace: true });
|
||||||
} else {
|
} else {
|
||||||
setError(res.message);
|
setError(res.message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -34,40 +34,69 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("Login");
|
document.title = t("Login");
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return <div className={"flex items-center justify-center w-full h-full bg-base-200"} id={"login-page"}>
|
return (
|
||||||
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
|
<div
|
||||||
<form onSubmit={onSubmit}>
|
className={"flex items-center justify-center w-full h-full bg-base-200"}
|
||||||
<div className={"card-body"}>
|
id={"login-page"}
|
||||||
<h1 className={"text-2xl font-bold"}>{t("Login")}</h1>
|
>
|
||||||
{error && <div role="alert" className="alert alert-error my-2">
|
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
<form onSubmit={onSubmit}>
|
||||||
viewBox="0 0 24 24">
|
<div className={"card-body"}>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<h1 className={"text-2xl font-bold"}>{t("Login")}</h1>
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
{error && (
|
||||||
</svg>
|
<div role="alert" className="alert alert-error my-2">
|
||||||
<span>{error}</span>
|
<svg
|
||||||
</div>}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<fieldset className="fieldset w-full">
|
className="h-6 w-6 shrink-0 stroke-current"
|
||||||
<legend className="fieldset-legend">{t("Username")}</legend>
|
fill="none"
|
||||||
<input type="text" className="input w-full" value={username} onChange={(e) => setUsername(e.target.value)}/>
|
viewBox="0 0 24 24"
|
||||||
</fieldset>
|
>
|
||||||
<fieldset className="fieldset w-full">
|
<path
|
||||||
<legend className="fieldset-legend">{t("Password")}</legend>
|
stroke-linecap="round"
|
||||||
<input type="password" className="input w-full" value={password} onChange={(e) => setPassword(e.target.value)}/>
|
stroke-linejoin="round"
|
||||||
</fieldset>
|
stroke-width="2"
|
||||||
<button className={"btn my-4 btn-primary"} type={"submit"}>
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
{isLoading && <span className="loading loading-spinner"></span>}
|
/>
|
||||||
{t("Continue")}
|
</svg>
|
||||||
</button>
|
<span>{error}</span>
|
||||||
<button className="btn" type={"button"} onClick={() => {
|
</div>
|
||||||
navigate("/register", {replace: true});
|
)}
|
||||||
}}>
|
<fieldset className="fieldset w-full">
|
||||||
{t("Don't have an account? Register")}
|
<legend className="fieldset-legend">{t("Username")}</legend>
|
||||||
</button>
|
<input
|
||||||
</div>
|
type="text"
|
||||||
</form>
|
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>
|
||||||
</div>
|
);
|
||||||
}
|
}
|
||||||
|
@@ -3,7 +3,11 @@ import { app } from "../app";
|
|||||||
import { ErrorAlert } from "../components/alert";
|
import { ErrorAlert } from "../components/alert";
|
||||||
import { network } from "../network/network";
|
import { network } from "../network/network";
|
||||||
import { ReactNode, useState } from "react";
|
import { ReactNode, useState } from "react";
|
||||||
import { MdOutlineAccountCircle, MdLockOutline, MdOutlineEditNote } from "react-icons/md";
|
import {
|
||||||
|
MdOutlineAccountCircle,
|
||||||
|
MdLockOutline,
|
||||||
|
MdOutlineEditNote,
|
||||||
|
} from "react-icons/md";
|
||||||
import Button from "../components/button";
|
import Button from "../components/button";
|
||||||
import showToast from "../components/toast";
|
import showToast from "../components/toast";
|
||||||
import { useNavigator } from "../components/navigator";
|
import { useNavigator } from "../components/navigator";
|
||||||
@@ -13,26 +17,44 @@ export function ManageMePage() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!app.user) {
|
if (!app.user) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
|
message={t("You are not logged in. Please log in to access this page.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="px-2">
|
return (
|
||||||
<ChangeAvatarDialog />
|
<div className="px-2">
|
||||||
<ChangeUsernameDialog />
|
<ChangeAvatarDialog />
|
||||||
<ChangePasswordDialog />
|
<ChangeUsernameDialog />
|
||||||
<ChangeBioDialog />
|
<ChangePasswordDialog />
|
||||||
</div>;
|
<ChangeBioDialog />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListTile({ title, icon, onClick }: { title: string, icon: ReactNode, onClick: () => void }) {
|
function ListTile({
|
||||||
return <div className="flex flex-row items-center h-12 px-2 bg-base-100 hover:bg-gray-200 cursor-pointer duration-200" onClick={onClick}>
|
title,
|
||||||
<div className="flex flex-row items-center">
|
icon,
|
||||||
<span className="text-2xl">
|
onClick,
|
||||||
{icon}
|
}: {
|
||||||
</span>
|
title: string;
|
||||||
<span className="ml-2">{title}</span>
|
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>
|
||||||
</div>
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChangeAvatarDialog() {
|
function ChangeAvatarDialog() {
|
||||||
@@ -73,41 +95,68 @@ function ChangeAvatarDialog() {
|
|||||||
showToast({
|
showToast({
|
||||||
message: t("Avatar changed successfully"),
|
message: t("Avatar changed successfully"),
|
||||||
type: "success",
|
type: "success",
|
||||||
})
|
});
|
||||||
const dialog = document.getElementById("change_avatar_dialog") as HTMLDialogElement;
|
const dialog = document.getElementById(
|
||||||
|
"change_avatar_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
if (dialog) {
|
if (dialog) {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<ListTile icon={<MdOutlineAccountCircle />} title={t("Change Avatar")} onClick={() => {
|
<>
|
||||||
const dialog = document.getElementById("change_avatar_dialog") as HTMLDialogElement;
|
<ListTile
|
||||||
if (dialog) {
|
icon={<MdOutlineAccountCircle />}
|
||||||
dialog.showModal();
|
title={t("Change Avatar")}
|
||||||
}
|
onClick={() => {
|
||||||
}} />
|
const dialog = document.getElementById(
|
||||||
<dialog id="change_avatar_dialog" className="modal">
|
"change_avatar_dialog",
|
||||||
<div className="modal-box">
|
) as HTMLDialogElement;
|
||||||
<h3 className="font-bold text-lg">{t("Change Avatar")}</h3>
|
if (dialog) {
|
||||||
<div className="h-48 flex items-center justify-center">
|
dialog.showModal();
|
||||||
<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"} />
|
/>
|
||||||
|
<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>
|
||||||
</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>
|
</div>
|
||||||
{error && <ErrorAlert message={error} className={"m-4"} />}
|
</dialog>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChangeUsernameDialog() {
|
function ChangeUsernameDialog() {
|
||||||
@@ -135,7 +184,9 @@ function ChangeUsernameDialog() {
|
|||||||
message: t("Username changed successfully"),
|
message: t("Username changed successfully"),
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
const dialog = document.getElementById("change_username_dialog") as HTMLDialogElement;
|
const dialog = document.getElementById(
|
||||||
|
"change_username_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
if (dialog) {
|
if (dialog) {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
@@ -144,44 +195,50 @@ function ChangeUsernameDialog() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<ListTile icon={<MdOutlineEditNote />} title={t("Change Username")} onClick={() => {
|
<>
|
||||||
const dialog = document.getElementById("change_username_dialog") as HTMLDialogElement;
|
<ListTile
|
||||||
if (dialog) {
|
icon={<MdOutlineEditNote />}
|
||||||
dialog.showModal();
|
title={t("Change Username")}
|
||||||
}
|
onClick={() => {
|
||||||
}} />
|
const dialog = document.getElementById(
|
||||||
<dialog id="change_username_dialog" className="modal">
|
"change_username_dialog",
|
||||||
<div className="modal-box">
|
) as HTMLDialogElement;
|
||||||
<h3 className="font-bold text-lg">{t("Change Username")}</h3>
|
if (dialog) {
|
||||||
<div className="input mt-4 w-full">
|
dialog.showModal();
|
||||||
<label className="label">
|
}
|
||||||
{t("New Username")}
|
}}
|
||||||
</label>
|
/>
|
||||||
<input
|
<dialog id="change_username_dialog" className="modal">
|
||||||
type="text"
|
<div className="modal-box">
|
||||||
placeholder={t("Enter new username")}
|
<h3 className="font-bold text-lg">{t("Change Username")}</h3>
|
||||||
value={newUsername}
|
<div className="input mt-4 w-full">
|
||||||
onChange={(e) => setNewUsername(e.target.value)}
|
<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>
|
</div>
|
||||||
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
</dialog>
|
||||||
<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>
|
|
||||||
</>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChangePasswordDialog() {
|
function ChangePasswordDialog() {
|
||||||
@@ -220,17 +277,19 @@ function ChangePasswordDialog() {
|
|||||||
// Update the token as it might have changed
|
// Update the token as it might have changed
|
||||||
app.token = res.data!.token;
|
app.token = res.data!.token;
|
||||||
app.user = res.data!;
|
app.user = res.data!;
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
message: t("Password changed successfully"),
|
message: t("Password changed successfully"),
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
|
|
||||||
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement;
|
const dialog = document.getElementById(
|
||||||
|
"change_password_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
if (dialog) {
|
if (dialog) {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setOldPassword("");
|
setOldPassword("");
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
@@ -239,68 +298,78 @@ function ChangePasswordDialog() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<ListTile icon={<MdLockOutline />} title={t("Change Password")} onClick={() => {
|
<>
|
||||||
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement;
|
<ListTile
|
||||||
if (dialog) {
|
icon={<MdLockOutline />}
|
||||||
dialog.showModal();
|
title={t("Change Password")}
|
||||||
}
|
onClick={() => {
|
||||||
}} />
|
const dialog = document.getElementById(
|
||||||
<dialog id="change_password_dialog" className="modal">
|
"change_password_dialog",
|
||||||
<div className="modal-box">
|
) as HTMLDialogElement;
|
||||||
<h3 className="font-bold text-lg mb-2">{t("Change Password")}</h3>
|
if (dialog) {
|
||||||
|
dialog.showModal();
|
||||||
<fieldset className="fieldset w-full">
|
}
|
||||||
<legend className="fieldset-legend">{t("Current Password")}</legend>
|
}}
|
||||||
<input
|
/>
|
||||||
type="password"
|
<dialog id="change_password_dialog" className="modal">
|
||||||
placeholder={t("Enter current password")}
|
<div className="modal-box">
|
||||||
value={oldPassword}
|
<h3 className="font-bold text-lg mb-2">{t("Change Password")}</h3>
|
||||||
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">
|
<fieldset className="fieldset w-full">
|
||||||
<legend className="fieldset-legend">{t("Confirm New Password")}</legend>
|
<legend className="fieldset-legend">{t("Current Password")}</legend>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t("Confirm new password")}
|
placeholder={t("Enter current password")}
|
||||||
value={confirmPassword}
|
value={oldPassword}
|
||||||
className="input w-full"
|
className="input w-full"
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
<fieldset className="fieldset w-full">
|
||||||
|
<legend className="fieldset-legend">{t("New Password")}</legend>
|
||||||
<div className="modal-action">
|
<input
|
||||||
<form method="dialog">
|
type="password"
|
||||||
<Button>{t("Close")}</Button>
|
placeholder={t("Enter new password")}
|
||||||
</form>
|
value={newPassword}
|
||||||
<Button
|
className="input w-full"
|
||||||
className="btn-primary"
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
onClick={handleSubmit}
|
/>
|
||||||
isLoading={isLoading}
|
</fieldset>
|
||||||
disabled={!oldPassword || !newPassword || !confirmPassword}
|
|
||||||
>
|
<fieldset className="fieldset w-full">
|
||||||
{t("Save")}
|
<legend className="fieldset-legend">
|
||||||
</Button>
|
{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>
|
||||||
</div>
|
</dialog>
|
||||||
</dialog>
|
</>
|
||||||
</>;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChangeBioDialog() {
|
function ChangeBioDialog() {
|
||||||
@@ -329,7 +398,9 @@ function ChangeBioDialog() {
|
|||||||
message: t("Bio changed successfully"),
|
message: t("Bio changed successfully"),
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
const dialog = document.getElementById("change_bio_dialog") as HTMLDialogElement;
|
const dialog = document.getElementById(
|
||||||
|
"change_bio_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
if (dialog) {
|
if (dialog) {
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
@@ -338,32 +409,44 @@ function ChangeBioDialog() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<ListTile icon={<MdOutlineEditNote />} title={t("Change Bio")} onClick={() => {
|
<>
|
||||||
const dialog = document.getElementById("change_bio_dialog") as HTMLDialogElement;
|
<ListTile
|
||||||
if (dialog) {
|
icon={<MdOutlineEditNote />}
|
||||||
dialog.showModal();
|
title={t("Change Bio")}
|
||||||
}
|
onClick={() => {
|
||||||
}} />
|
const dialog = document.getElementById(
|
||||||
<dialog id="change_bio_dialog" className="modal">
|
"change_bio_dialog",
|
||||||
<div className="modal-box">
|
) as HTMLDialogElement;
|
||||||
<h3 className="font-bold text-lg">{t("Change Bio")}</h3>
|
if (dialog) {
|
||||||
<Input value={bio} onChange={(e) => setBio(e.target.value)} label={"bio"} />
|
dialog.showModal();
|
||||||
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
}
|
||||||
<div className="modal-action">
|
}}
|
||||||
<form method="dialog">
|
/>
|
||||||
<Button>{t("Close")}</Button>
|
<dialog id="change_bio_dialog" className="modal">
|
||||||
</form>
|
<div className="modal-box">
|
||||||
<Button
|
<h3 className="font-bold text-lg">{t("Change Bio")}</h3>
|
||||||
className="btn-primary"
|
<Input
|
||||||
onClick={handleSubmit}
|
value={bio}
|
||||||
isLoading={isLoading}
|
onChange={(e) => setBio(e.target.value)}
|
||||||
disabled={!bio.trim()}
|
label={"bio"}
|
||||||
>
|
/>
|
||||||
{t("Save")}
|
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
</dialog>
|
||||||
</dialog>
|
</>
|
||||||
</>;
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,9 @@
|
|||||||
import { MdMenu, MdOutlineBadge, MdOutlinePerson, MdOutlineStorage } from "react-icons/md";
|
import {
|
||||||
|
MdMenu,
|
||||||
|
MdOutlineBadge,
|
||||||
|
MdOutlinePerson,
|
||||||
|
MdOutlineStorage,
|
||||||
|
} from "react-icons/md";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import StorageView from "./manage_storage_page.tsx";
|
import StorageView from "./manage_storage_page.tsx";
|
||||||
import UserView from "./manage_user_page.tsx";
|
import UserView from "./manage_user_page.tsx";
|
||||||
@@ -26,70 +31,89 @@ export default function ManagePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("Manage");
|
document.title = t("Manage");
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const buildItem = (title: string, icon: ReactNode, p: number) => {
|
const buildItem = (title: string, icon: ReactNode, p: number) => {
|
||||||
return <li key={title} onClick={() => {
|
return (
|
||||||
setPage(p);
|
<li
|
||||||
const checkbox = document.getElementById("my-drawer-2") as HTMLInputElement;
|
key={title}
|
||||||
if (checkbox) {
|
onClick={() => {
|
||||||
checkbox.checked = false;
|
setPage(p);
|
||||||
}
|
const checkbox = document.getElementById(
|
||||||
}} className={"my-1"}>
|
"my-drawer-2",
|
||||||
<a className={`flex items-center h-9 px-4 ${page == p && "bg-primary text-primary-content"}`}>
|
) as HTMLInputElement;
|
||||||
{icon}
|
if (checkbox) {
|
||||||
<span className={"text"}>
|
checkbox.checked = false;
|
||||||
{title}
|
}
|
||||||
</span>
|
}}
|
||||||
</a>
|
className={"my-1"}
|
||||||
</li>
|
>
|
||||||
}
|
<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 = [
|
const pageNames = [t("My Info"), t("Storage"), t("Users"), t("Server")];
|
||||||
t("My Info"),
|
|
||||||
t("Storage"),
|
|
||||||
t("Users"),
|
|
||||||
t("Server"),
|
|
||||||
]
|
|
||||||
|
|
||||||
const pageComponents = [
|
const pageComponents = [
|
||||||
<ManageMePage />,
|
<ManageMePage />,
|
||||||
<StorageView />,
|
<StorageView />,
|
||||||
<UserView />,
|
<UserView />,
|
||||||
<ManageServerConfigPage />,
|
<ManageServerConfigPage />,
|
||||||
]
|
];
|
||||||
|
|
||||||
return <div className="drawer lg:drawer-open">
|
return (
|
||||||
<input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
|
<div className="drawer lg:drawer-open">
|
||||||
<div className="drawer-content" style={{
|
<input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
|
||||||
height: "calc(100vh - 64px)",
|
<div
|
||||||
}}>
|
className="drawer-content"
|
||||||
<div className={"flex w-full h-14 items-center gap-2 px-3"}>
|
style={{
|
||||||
<label className={"btn btn-square btn-ghost lg:hidden"} htmlFor="my-drawer-2">
|
height: "calc(100vh - 64px)",
|
||||||
<MdMenu size={24} />
|
}}
|
||||||
</label>
|
>
|
||||||
<h1 className={"text-xl font-bold"}>
|
<div className={"flex w-full h-14 items-center gap-2 px-3"}>
|
||||||
{pageNames[page]}
|
<label
|
||||||
</h1>
|
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>
|
||||||
<div>
|
<div
|
||||||
{pageComponents[page]}
|
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>
|
</div>
|
||||||
<div className="drawer-side" style={{
|
);
|
||||||
height: lg ? "calc(100vh - 64px)" : "100vh",
|
|
||||||
}}>
|
|
||||||
<label htmlFor="my-drawer-2" aria-label="close sidebar" className="drawer-overlay"></label>
|
|
||||||
<ul className="menu bg-base-100 min-h-full lg:min-h-0 w-72 px-4 lg:mt-1">
|
|
||||||
<h2 className={"text-lg font-bold p-4"}>
|
|
||||||
{t("Manage")}
|
|
||||||
</h2>
|
|
||||||
{buildItem(t("My Info"), <MdOutlineBadge className={"text-xl"} />, 0)}
|
|
||||||
{buildItem(t("Storage"), <MdOutlineStorage className={"text-xl"} />, 1)}
|
|
||||||
{buildItem(t("Users"), <MdOutlinePerson className={"text-xl"} />, 2)}
|
|
||||||
{buildItem(t("Server"), <MdOutlineStorage className={"text-xl"} />, 3)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { app } from "../app"
|
import { app } from "../app";
|
||||||
import { ErrorAlert, InfoAlert } from "../components/alert"
|
import { ErrorAlert, InfoAlert } from "../components/alert";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ServerConfig } from "../network/models";
|
import { ServerConfig } from "../network/models";
|
||||||
import Loading from "../components/loading";
|
import Loading from "../components/loading";
|
||||||
import Input, {TextArea} from "../components/input";
|
import Input, { TextArea } from "../components/input";
|
||||||
import { network } from "../network/network";
|
import { network } from "../network/network";
|
||||||
import showToast from "../components/toast";
|
import showToast from "../components/toast";
|
||||||
import Button from "../components/button";
|
import Button from "../components/button";
|
||||||
@@ -24,21 +24,31 @@ export default function ManageServerConfigPage() {
|
|||||||
showToast({
|
showToast({
|
||||||
message: res.message,
|
message: res.message,
|
||||||
type: "error",
|
type: "error",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!app.user) {
|
if (!app.user) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
|
message={t("You are not logged in. Please log in to access this page.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.user?.is_admin) {
|
if (!app.user?.is_admin) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
|
message={t("You are not authorized to access this page.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
return <Loading />
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -62,40 +72,107 @@ export default function ManageServerConfigPage() {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return <form className="px-4 pb-4" onSubmit={handleSubmit}>
|
return (
|
||||||
<Input type="number" value={config.max_uploading_size_in_mb.toString()} label="Max uploading size (MB)" onChange={(e) => {
|
<form className="px-4 pb-4" onSubmit={handleSubmit}>
|
||||||
setConfig({...config, max_uploading_size_in_mb: parseInt(e.target.value) })
|
<Input
|
||||||
}}></Input>
|
type="number"
|
||||||
<Input type="number" value={config.max_file_size_in_mb.toString()} label="Max file size (MB)" onChange={(e) => {
|
value={config.max_uploading_size_in_mb.toString()}
|
||||||
setConfig({...config, max_file_size_in_mb: parseInt(e.target.value) })
|
label="Max uploading size (MB)"
|
||||||
}}></Input>
|
onChange={(e) => {
|
||||||
<Input type="number" value={config.max_downloads_per_day_for_single_ip.toString()} label="Max downloads per day for single IP" onChange={(e) => {
|
setConfig({
|
||||||
setConfig({...config, max_downloads_per_day_for_single_ip: parseInt(e.target.value) })
|
...config,
|
||||||
}}></Input>
|
max_uploading_size_in_mb: parseInt(e.target.value),
|
||||||
<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) => {
|
></Input>
|
||||||
setConfig({ ...config, allow_register: e.target.checked })
|
<Input
|
||||||
}} />
|
type="number"
|
||||||
</fieldset>
|
value={config.max_file_size_in_mb.toString()}
|
||||||
<Input type="text" value={config.server_name} label="Server name" onChange={(e) => {
|
label="Max file size (MB)"
|
||||||
setConfig({...config, server_name: e.target.value })
|
onChange={(e) => {
|
||||||
}}></Input>
|
setConfig({
|
||||||
<Input type="text" value={config.server_description} label="Server description" onChange={(e) => {
|
...config,
|
||||||
setConfig({...config, server_description: e.target.value })
|
max_file_size_in_mb: parseInt(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>
|
<Input
|
||||||
<Input type="text" value={config.cloudflare_turnstile_secret_key} label="Cloudflare Turnstile Secret Key" onChange={(e) => {
|
type="number"
|
||||||
setConfig({...config, cloudflare_turnstile_secret_key: e.target.value })
|
value={config.max_downloads_per_day_for_single_ip.toString()}
|
||||||
}}></Input>
|
label="Max downloads per day for single IP"
|
||||||
<TextArea value={config.site_info} onChange={(e) => {
|
onChange={(e) => {
|
||||||
setConfig({...config, site_info: e.target.value })
|
setConfig({
|
||||||
}} label="Site info (Markdown)" height={180} />
|
...config,
|
||||||
<InfoAlert className="my-2" message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download." />
|
max_downloads_per_day_for_single_ip: parseInt(e.target.value),
|
||||||
<div className="flex justify-end">
|
});
|
||||||
<Button className="btn-accent shadow" isLoading={isLoading}>{t("Submit")}</Button>
|
}}
|
||||||
</div>
|
></Input>
|
||||||
</form>
|
<fieldset className="fieldset w-full">
|
||||||
}
|
<legend className="fieldset-legend">Allow register</legend>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.allow_register}
|
||||||
|
className="toggle-primary toggle"
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfig({ ...config, allow_register: e.target.checked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={config.server_name}
|
||||||
|
label="Server name"
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfig({ ...config, server_name: e.target.value });
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={config.server_description}
|
||||||
|
label="Server description"
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfig({ ...config, server_description: e.target.value });
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={config.cloudflare_turnstile_site_key}
|
||||||
|
label="Cloudflare Turnstile Site Key"
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
cloudflare_turnstile_site_key: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={config.cloudflare_turnstile_secret_key}
|
||||||
|
label="Cloudflare Turnstile Secret Key"
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
cloudflare_turnstile_secret_key: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
<TextArea
|
||||||
|
value={config.site_info}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfig({ ...config, site_info: e.target.value });
|
||||||
|
}}
|
||||||
|
label="Site info (Markdown)"
|
||||||
|
height={180}
|
||||||
|
/>
|
||||||
|
<InfoAlert
|
||||||
|
className="my-2"
|
||||||
|
message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download."
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button className="btn-accent shadow" isLoading={isLoading}>
|
||||||
|
{t("Submit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -23,36 +23,46 @@ export default function StorageView() {
|
|||||||
} else {
|
} else {
|
||||||
showToast({
|
showToast({
|
||||||
message: response.message,
|
message: response.message,
|
||||||
type: "error"
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!app.user) {
|
if (!app.user) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
|
message={t("You are not logged in. Please log in to access this page.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.user?.is_admin) {
|
if (!app.user?.is_admin) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
|
message={t("You are not authorized to access this page.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storages == null) {
|
if (storages == null) {
|
||||||
return <Loading />
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateStorages = async () => {
|
const updateStorages = async () => {
|
||||||
setStorages(null)
|
setStorages(null);
|
||||||
const response = await network.listStorages();
|
const response = await network.listStorages();
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setStorages(response.data!);
|
setStorages(response.data!);
|
||||||
} else {
|
} else {
|
||||||
showToast({
|
showToast({
|
||||||
message: response.message,
|
message: response.message,
|
||||||
type: "error"
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (loadingId != null) {
|
if (loadingId != null) {
|
||||||
@@ -68,82 +78,114 @@ export default function StorageView() {
|
|||||||
} else {
|
} else {
|
||||||
showToast({
|
showToast({
|
||||||
message: response.message,
|
message: response.message,
|
||||||
type: "error"
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setLoadingId(null);
|
setLoadingId(null);
|
||||||
}
|
};
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<div role="alert" className={`alert alert-info alert-outline ${storages.length !== 0 && "hidden"} mx-4 mb-4`}>
|
<>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
<div
|
||||||
className="h-6 w-6 shrink-0 stroke-current">
|
role="alert"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
className={`alert alert-info alert-outline ${storages.length !== 0 && "hidden"} mx-4 mb-4`}
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
>
|
||||||
</svg>
|
<svg
|
||||||
<span>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
{t("No storage found. Please create a new storage.")}
|
fill="none"
|
||||||
</span>
|
viewBox="0 0 24 24"
|
||||||
</div>
|
className="h-6 w-6 shrink-0 stroke-current"
|
||||||
<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"}>
|
<path
|
||||||
<thead>
|
stroke-linecap="round"
|
||||||
<tr>
|
stroke-linejoin="round"
|
||||||
<td>{t("Name")}</td>
|
stroke-width="2"
|
||||||
<td>{t("Created At")}</td>
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
<td>{t("Space")}</td>
|
></path>
|
||||||
<td>{t("Action")}</td>
|
</svg>
|
||||||
</tr>
|
<span>{t("No storage found. Please create a new storage.")}</span>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<div
|
||||||
{
|
className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto ${storages.length === 0 ? "hidden" : ""}`}
|
||||||
storages.map((s) => {
|
>
|
||||||
return <tr key={s.id} className={"hover"}>
|
<table className={"table"}>
|
||||||
<td>
|
<thead>
|
||||||
{s.name}
|
<tr>
|
||||||
</td>
|
<td>{t("Name")}</td>
|
||||||
<td>
|
<td>{t("Created At")}</td>
|
||||||
{(new Date(s.createdAt)).toLocaleString()}
|
<td>{t("Space")}</td>
|
||||||
</td>
|
<td>{t("Action")}</td>
|
||||||
<td>
|
</tr>
|
||||||
{(s.currentSize / 1024 / 1024).toFixed(2)} / {s.maxSize / 1024 / 1024} MB
|
</thead>
|
||||||
</td>
|
<tbody>
|
||||||
<td>
|
{storages.map((s) => {
|
||||||
<button className={"btn btn-square"} type={"button"} onClick={() => {
|
return (
|
||||||
const dialog = document.getElementById(`confirm_delete_dialog_${s.id}`) as HTMLDialogElement;
|
<tr key={s.id} className={"hover"}>
|
||||||
dialog.showModal();
|
<td>{s.name}</td>
|
||||||
}}>
|
<td>{new Date(s.createdAt).toLocaleString()}</td>
|
||||||
{loadingId === s.id ? <span className={"loading loading-spinner loading-sm"}></span> : <MdDelete size={24} />}
|
<td>
|
||||||
</button>
|
{(s.currentSize / 1024 / 1024).toFixed(2)} /{" "}
|
||||||
<dialog id={`confirm_delete_dialog_${s.id}`} className="modal">
|
{s.maxSize / 1024 / 1024} MB
|
||||||
<div className="modal-box">
|
</td>
|
||||||
<h3 className="text-lg font-bold">{t("Delete Storage")}</h3>
|
<td>
|
||||||
<p className="py-4">
|
<button
|
||||||
{t("Are you sure you want to delete this storage? This action cannot be undone.")}
|
className={"btn btn-square"}
|
||||||
</p>
|
type={"button"}
|
||||||
<div className="modal-action">
|
onClick={() => {
|
||||||
<form method="dialog">
|
const dialog = document.getElementById(
|
||||||
<button className="btn">{t("Cancel")}</button>
|
`confirm_delete_dialog_${s.id}`,
|
||||||
</form>
|
) as HTMLDialogElement;
|
||||||
<button className="btn btn-error" onClick={() => {
|
dialog.showModal();
|
||||||
handleDelete(s.id);
|
}}
|
||||||
}}>
|
>
|
||||||
{t("Delete")}
|
{loadingId === s.id ? (
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</dialog>
|
||||||
</dialog>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
);
|
||||||
})
|
})}
|
||||||
}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
<div className={"flex flex-row-reverse px-4"}>
|
||||||
<div className={"flex flex-row-reverse px-4"}>
|
<NewStorageDialog onAdded={updateStorages} />
|
||||||
<NewStorageDialog onAdded={updateStorages} />
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StorageType {
|
enum StorageType {
|
||||||
@@ -183,14 +225,33 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response = await network.createLocalStorage(params.name, params.path, params.maxSizeInMB);
|
response = await network.createLocalStorage(
|
||||||
|
params.name,
|
||||||
|
params.path,
|
||||||
|
params.maxSizeInMB,
|
||||||
|
);
|
||||||
} else if (storageType === StorageType.s3) {
|
} else if (storageType === StorageType.s3) {
|
||||||
if (params.endPoint === "" || params.accessKeyID === "" || params.secretAccessKey === "" || params.bucketName === "" || params.name === "" || params.maxSizeInMB <= 0) {
|
if (
|
||||||
|
params.endPoint === "" ||
|
||||||
|
params.accessKeyID === "" ||
|
||||||
|
params.secretAccessKey === "" ||
|
||||||
|
params.bucketName === "" ||
|
||||||
|
params.name === "" ||
|
||||||
|
params.maxSizeInMB <= 0
|
||||||
|
) {
|
||||||
setError(t("All fields are required"));
|
setError(t("All fields are required"));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response = await network.createS3Storage(params.name, params.endPoint, params.accessKeyID, params.secretAccessKey, params.bucketName, params.maxSizeInMB, params.domain);
|
response = await network.createS3Storage(
|
||||||
|
params.name,
|
||||||
|
params.endPoint,
|
||||||
|
params.accessKeyID,
|
||||||
|
params.secretAccessKey,
|
||||||
|
params.bucketName,
|
||||||
|
params.maxSizeInMB,
|
||||||
|
params.domain,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response!.success) {
|
if (response!.success) {
|
||||||
@@ -198,165 +259,240 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
|||||||
message: t("Storage created successfully"),
|
message: t("Storage created successfully"),
|
||||||
});
|
});
|
||||||
onAdded();
|
onAdded();
|
||||||
const dialog = document.getElementById("new_storage_dialog") as HTMLDialogElement;
|
const dialog = document.getElementById(
|
||||||
|
"new_storage_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
dialog.close();
|
dialog.close();
|
||||||
} else {
|
} else {
|
||||||
setError(response!.message);
|
setError(response!.message);
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<button className="btn" onClick={() => {
|
<>
|
||||||
const dialog = document.getElementById("new_storage_dialog") as HTMLDialogElement;
|
<button
|
||||||
dialog.showModal();
|
className="btn"
|
||||||
}}>
|
onClick={() => {
|
||||||
<MdAdd />
|
const dialog = document.getElementById(
|
||||||
{t("New Storage")}
|
"new_storage_dialog",
|
||||||
</button>
|
) as HTMLDialogElement;
|
||||||
<dialog id="new_storage_dialog" className="modal">
|
dialog.showModal();
|
||||||
<div className="modal-box">
|
}}
|
||||||
<h3 className="font-bold text-lg pb-4">{t("New Storage")}</h3>
|
>
|
||||||
|
<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>
|
<p className={"text-sm font-bold p-2"}>{t("Type")}</p>
|
||||||
<form className="filter mb-2">
|
<form className="filter mb-2">
|
||||||
<input className="btn btn-square" type="reset" value="×" onClick={() => {
|
<input
|
||||||
setStorageType(null);
|
className="btn btn-square"
|
||||||
}} />
|
type="reset"
|
||||||
<input className="btn" type="radio" name="type" aria-label={t("Local")} onInput={() => {
|
value="×"
|
||||||
setStorageType(StorageType.local);
|
onClick={() => {
|
||||||
}} />
|
setStorageType(null);
|
||||||
<input className="btn" type="radio" name="type" aria-label={t("S3")} onInput={() => {
|
}}
|
||||||
setStorageType(StorageType.s3);
|
/>
|
||||||
}} />
|
<input
|
||||||
</form>
|
className="btn"
|
||||||
|
type="radio"
|
||||||
{
|
name="type"
|
||||||
storageType === StorageType.local && <>
|
aria-label={t("Local")}
|
||||||
<label className="input w-full my-2">
|
onInput={() => {
|
||||||
{t("Name")}
|
setStorageType(StorageType.local);
|
||||||
<input type="text" className="w-full" value={params.name} onChange={(e) => {
|
}}
|
||||||
setParams({
|
/>
|
||||||
...params,
|
<input
|
||||||
name: e.target.value,
|
className="btn"
|
||||||
})
|
type="radio"
|
||||||
}} />
|
name="type"
|
||||||
</label>
|
aria-label={t("S3")}
|
||||||
<label className="input w-full my-2">
|
onInput={() => {
|
||||||
{t("Path")}
|
setStorageType(StorageType.s3);
|
||||||
<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>
|
</form>
|
||||||
<button className={"btn btn-primary"} onClick={handleSubmit} type={"button"}>
|
|
||||||
{isLoading && <span className={"loading loading-spinner loading-sm mr-2"}></span>}
|
{storageType === StorageType.local && (
|
||||||
{t("Submit")}
|
<>
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</dialog>
|
||||||
</dialog>
|
</>
|
||||||
</>
|
);
|
||||||
}
|
}
|
||||||
|
@@ -19,35 +19,72 @@ export default function UserView() {
|
|||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
|
||||||
if (!app.user) {
|
if (!app.user) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
|
message={t("You are not logged in. Please log in to access this page.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.user?.is_admin) {
|
if (!app.user?.is_admin) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
|
message={t("You are not authorized to access this page.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<div className={"flex flex-row justify-between items-center mx-4 my-4"}>
|
<>
|
||||||
<form className={"flex flex-row gap-2 items-center w-64"} onSubmit={(e) => {
|
<div className={"flex flex-row justify-between items-center mx-4 my-4"}>
|
||||||
e.preventDefault();
|
<form
|
||||||
setPage(0);
|
className={"flex flex-row gap-2 items-center w-64"}
|
||||||
const input = e.currentTarget.querySelector("input[type=search]") as HTMLInputElement;
|
onSubmit={(e) => {
|
||||||
setSearchKeyword(input.value);
|
e.preventDefault();
|
||||||
}}>
|
setPage(0);
|
||||||
<label className="input">
|
const input = e.currentTarget.querySelector(
|
||||||
<MdSearch size={20} className="opacity-50" />
|
"input[type=search]",
|
||||||
<input type="search" className="grow" placeholder={t("Search")} id="search" />
|
) as HTMLInputElement;
|
||||||
</label>
|
setSearchKeyword(input.value);
|
||||||
</form>
|
}}
|
||||||
</div>
|
>
|
||||||
<UserTable page={page} searchKeyword={searchKeyword} key={`${page}&${searchKeyword}`} totalPagesCallback={setTotalPages} />
|
<label className="input">
|
||||||
<div className={"flex flex-row justify-center items-center my-4"}>
|
<MdSearch size={20} className="opacity-50" />
|
||||||
{totalPages ? <Pagination page={page} setPage={setPage} totalPages={totalPages} /> : null}
|
<input
|
||||||
</div>
|
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 { t } = useTranslation();
|
||||||
const [users, setUsers] = useState<User[] | null>(null);
|
const [users, setUsers] = useState<User[] | null>(null);
|
||||||
|
|
||||||
@@ -61,7 +98,7 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
|
|||||||
showToast({
|
showToast({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: response.message,
|
message: response.message,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -73,7 +110,7 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
|
|||||||
showToast({
|
showToast({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: response.message,
|
message: response.message,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -92,29 +129,31 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
|
|||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto`}>
|
return (
|
||||||
<table className={"table"}>
|
<div
|
||||||
<thead>
|
className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto`}
|
||||||
<tr>
|
>
|
||||||
<td>{t("Username")}</td>
|
<table className={"table"}>
|
||||||
<td>{t("Created At")}</td>
|
<thead>
|
||||||
<td>{t("Admin")}</td>
|
<tr>
|
||||||
<td>{t("Can Upload")}</td>
|
<td>{t("Username")}</td>
|
||||||
<td>{t("Actions")}</td>
|
<td>{t("Created At")}</td>
|
||||||
</tr>
|
<td>{t("Admin")}</td>
|
||||||
</thead>
|
<td>{t("Can Upload")}</td>
|
||||||
<tbody>
|
<td>{t("Actions")}</td>
|
||||||
{
|
</tr>
|
||||||
users.map((u) => {
|
</thead>
|
||||||
return <UserRow key={u.id} user={u} onChanged={handleChanged} />
|
<tbody>
|
||||||
})
|
{users.map((u) => {
|
||||||
}
|
return <UserRow key={u.id} user={u} onChanged={handleChanged} />;
|
||||||
</tbody>
|
})}
|
||||||
</table>
|
</tbody>
|
||||||
</div>
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
function UserRow({ user, onChanged }: { user: User; onChanged: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -139,7 +178,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSetAdmin = async () => {
|
const handleSetAdmin = async () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -160,7 +199,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSetUser = async () => {
|
const handleSetUser = async () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -181,7 +220,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSetUploadPermission = async () => {
|
const handleSetUploadPermission = async () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -202,7 +241,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleRemoveUploadPermission = async () => {
|
const handleRemoveUploadPermission = async () => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -223,55 +262,86 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
return <tr key={user.id} className={"hover"}>
|
return (
|
||||||
<td>
|
<tr key={user.id} className={"hover"}>
|
||||||
{user.username}
|
<td>{user.username}</td>
|
||||||
</td>
|
<td>{new Date(user.created_at).toLocaleDateString()}</td>
|
||||||
<td>
|
<td>{user.is_admin ? t("Yes") : t("No")}</td>
|
||||||
{(new Date(user.created_at)).toLocaleDateString()}
|
<td>{user.can_upload ? t("Yes") : t("No")}</td>
|
||||||
</td>
|
<td>
|
||||||
<td>
|
<div className="dropdown dropdown-bottom dropdown-end">
|
||||||
{user.is_admin ? t("Yes") : t("No")}
|
<button
|
||||||
</td>
|
ref={buttonRef}
|
||||||
<td>
|
className="btn btn-square m-1"
|
||||||
{user.can_upload ? t("Yes") : t("No")}
|
onClick={() => {
|
||||||
</td>
|
showPopup(
|
||||||
<td>
|
<ul className="menu bg-base-100 rounded-box z-1 w-64 p-2 shadow-sm">
|
||||||
<div className="dropdown dropdown-bottom dropdown-end">
|
<h4 className="text-sm font-bold px-3 py-1 text-primary">
|
||||||
<button ref={buttonRef} className="btn btn-square m-1" onClick={() => {
|
{t("Actions")}
|
||||||
showPopup(<ul className="menu bg-base-100 rounded-box z-1 w-64 p-2 shadow-sm">
|
</h4>
|
||||||
<h4 className="text-sm font-bold px-3 py-1 text-primary">{t("Actions")}</h4>
|
<PopupMenuItem
|
||||||
<PopupMenuItem onClick={() => {
|
onClick={() => {
|
||||||
const dialog = document.getElementById(`delete_user_dialog_${user.id}`) as HTMLDialogElement;
|
const dialog = document.getElementById(
|
||||||
dialog.showModal();
|
`delete_user_dialog_${user.id}`,
|
||||||
}}>
|
) as HTMLDialogElement;
|
||||||
<a>{t("Delete")}</a>
|
dialog.showModal();
|
||||||
</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 ? (
|
<a>{t("Delete")}</a>
|
||||||
user.can_upload ? <PopupMenuItem onClick={handleRemoveUploadPermission}><a>{t("Remove upload permission")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetUploadPermission}><a>{t("Grant upload permission")}</a></PopupMenuItem>
|
</PopupMenuItem>
|
||||||
) : null}
|
{user.is_admin ? (
|
||||||
</ul>, buttonRef.current!);
|
<PopupMenuItem onClick={handleSetUser}>
|
||||||
}}>
|
<a>{t("Set as user")}</a>
|
||||||
{isLoading
|
</PopupMenuItem>
|
||||||
? <span className="loading loading-spinner loading-sm"></span>
|
) : (
|
||||||
: <MdMoreHoriz size={20} className="opacity-50" />}
|
<PopupMenuItem onClick={handleSetAdmin}>
|
||||||
</button>
|
<a>{t("Set as admin")}</a>
|
||||||
<dialog id={`delete_user_dialog_${user.id}`} className="modal">
|
</PopupMenuItem>
|
||||||
<div className="modal-box">
|
)}
|
||||||
<h3 className="font-bold text-lg">{t("Delete User")}</h3>
|
{app.user?.is_admin ? (
|
||||||
<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>
|
user.can_upload ? (
|
||||||
<div className="modal-action">
|
<PopupMenuItem onClick={handleRemoveUploadPermission}>
|
||||||
<form method="dialog">
|
<a>{t("Remove upload permission")}</a>
|
||||||
<button className="btn btn-ghost">{t("Close")}</button>
|
</PopupMenuItem>
|
||||||
<button className="btn btn-error" onClick={handleDelete}>{t("Delete")}</button>
|
) : (
|
||||||
</form>
|
<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>
|
||||||
</div>
|
</dialog>
|
||||||
</dialog>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,238 +1,310 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {MdAdd, MdClose, MdDelete, MdOutlineInfo} from "react-icons/md";
|
import { MdAdd, MdClose, MdDelete, MdOutlineInfo } from "react-icons/md";
|
||||||
import { Tag } from "../network/models.ts";
|
import { Tag } from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import {useAppContext} from "../components/AppContext.tsx";
|
import { useAppContext } from "../components/AppContext.tsx";
|
||||||
import TagInput, {QuickAddTagDialog} from "../components/tag_input.tsx";
|
import TagInput, { QuickAddTagDialog } from "../components/tag_input.tsx";
|
||||||
import {ImageDrapArea, SelectAndUploadImageButton, UploadClipboardImageButton} from "../components/image_selector.tsx";
|
import {
|
||||||
|
ImageDrapArea,
|
||||||
|
SelectAndUploadImageButton,
|
||||||
|
UploadClipboardImageButton,
|
||||||
|
} from "../components/image_selector.tsx";
|
||||||
|
|
||||||
export default function PublishPage() {
|
export default function PublishPage() {
|
||||||
const [title, setTitle] = useState<string>("")
|
const [title, setTitle] = useState<string>("");
|
||||||
const [altTitles, setAltTitles] = useState<string[]>([])
|
const [altTitles, setAltTitles] = useState<string[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([])
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [article, setArticle] = useState<string>("")
|
const [article, setArticle] = useState<string>("");
|
||||||
const [images, setImages] = useState<number[]>([])
|
const [images, setImages] = useState<number[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setSubmitting] = useState(false)
|
const [isSubmitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const appContext = useAppContext()
|
const appContext = useAppContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("Publish Resource");
|
document.title = t("Publish Resource");
|
||||||
}, [t])
|
}, [t]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (!title) {
|
if (!title) {
|
||||||
setError(t("Title cannot be empty"))
|
setError(t("Title cannot be empty"));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < altTitles.length; i++) {
|
for (let i = 0; i < altTitles.length; i++) {
|
||||||
if (!altTitles[i]) {
|
if (!altTitles[i]) {
|
||||||
setError(t("Alternative title cannot be empty"))
|
setError(t("Alternative title cannot be empty"));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!tags || tags.length === 0) {
|
if (!tags || tags.length === 0) {
|
||||||
setError(t("At least one tag required"))
|
setError(t("At least one tag required"));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (!article) {
|
if (!article) {
|
||||||
setError(t("Description cannot be empty"))
|
setError(t("Description cannot be empty"));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true);
|
||||||
const res = await network.createResource({
|
const res = await network.createResource({
|
||||||
title: title,
|
title: title,
|
||||||
alternative_titles: altTitles,
|
alternative_titles: altTitles,
|
||||||
tags: tags.map((tag) => tag.id),
|
tags: tags.map((tag) => tag.id),
|
||||||
article: article,
|
article: article,
|
||||||
images: images,
|
images: images,
|
||||||
})
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setSubmitting(false)
|
setSubmitting(false);
|
||||||
appContext.clear();
|
appContext.clear();
|
||||||
navigate("/resources/" + res.data!, { replace: true })
|
navigate("/resources/" + res.data!, { replace: true });
|
||||||
} else {
|
} else {
|
||||||
setSubmitting(false)
|
setSubmitting(false);
|
||||||
setError(res.message)
|
setError(res.message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (!app.user) {
|
if (!app.user) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
|
message={t("You are not logged in. Please log in to access this page.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!app.canUpload()) {
|
if (!app.canUpload()) {
|
||||||
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
|
return (
|
||||||
|
<ErrorAlert
|
||||||
|
className={"m-4"}
|
||||||
|
message={t("You are not authorized to access this page.")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ImageDrapArea onUploaded={(images) => {
|
return (
|
||||||
setImages((prev) => ([...prev, ...images]));
|
<ImageDrapArea
|
||||||
}}>
|
onUploaded={(images) => {
|
||||||
<div className={"p-4"}>
|
setImages((prev) => [...prev, ...images]);
|
||||||
<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} />
|
<div className={"p-4"}>
|
||||||
<span>{t("All information can be modified after publishing")}</span>
|
<h1 className={"text-2xl font-bold my-4"}>{t("Publish Resource")}</h1>
|
||||||
</div>
|
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
||||||
<p className={"my-1"}>{t("Title")}</p>
|
<MdOutlineInfo size={24} />
|
||||||
<input type="text" className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
|
<span>{t("All information can be modified after publishing")}</span>
|
||||||
<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>
|
<p className={"my-1"}>{t("Title")}</p>
|
||||||
<div className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}>
|
<input
|
||||||
<table className={"table"}>
|
type="text"
|
||||||
<thead>
|
className="input w-full"
|
||||||
<tr>
|
value={title}
|
||||||
<td>{t("Preview")}</td>
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
<td>{t("Link")}</td>
|
/>
|
||||||
<td>{t("Action")}</td>
|
<div className={"h-4"}></div>
|
||||||
</tr>
|
<p className={"my-1"}>{t("Alternative Titles")}</p>
|
||||||
</thead>
|
{altTitles.map((title, index) => {
|
||||||
<tbody>
|
return (
|
||||||
{
|
<div key={index} className={"flex items-center my-2"}>
|
||||||
images.map((image, index) => {
|
<input
|
||||||
return <tr key={index} className={"hover"}>
|
type="text"
|
||||||
<td>
|
className="input w-full"
|
||||||
<img src={network.getImageUrl(image)} className={"w-16 h-16 object-cover card"} alt={"image"} />
|
value={title}
|
||||||
</td>
|
onChange={(e) => {
|
||||||
<td>
|
const newAltTitles = [...altTitles];
|
||||||
{network.getImageUrl(image)}
|
newAltTitles[index] = e.target.value;
|
||||||
</td>
|
setAltTitles(newAltTitles);
|
||||||
<td>
|
}}
|
||||||
<button className={"btn btn-square"} type={"button"} onClick={() => {
|
/>
|
||||||
const id = images[index]
|
<button
|
||||||
const newImages = [...images]
|
className={"btn btn-square btn-error ml-2"}
|
||||||
newImages.splice(index, 1)
|
type={"button"}
|
||||||
setImages(newImages)
|
onClick={() => {
|
||||||
network.deleteImage(id)
|
const newAltTitles = [...altTitles];
|
||||||
}}>
|
newAltTitles.splice(index, 1);
|
||||||
<MdDelete size={24} />
|
setAltTitles(newAltTitles);
|
||||||
</button>
|
}}
|
||||||
</td>
|
>
|
||||||
</tr>
|
<MdDelete size={24} />
|
||||||
})
|
</button>
|
||||||
}
|
</div>
|
||||||
</tbody>
|
);
|
||||||
</table>
|
})}
|
||||||
</div>
|
<button
|
||||||
<div className={"flex"}>
|
className={"btn my-2"}
|
||||||
<SelectAndUploadImageButton onUploaded={(images) => {
|
type={"button"}
|
||||||
setImages((prev) => ([...prev, ...images]));
|
onClick={() => {
|
||||||
}}/>
|
setAltTitles([...altTitles, ""]);
|
||||||
<span className={"w-4"}></span>
|
}}
|
||||||
<UploadClipboardImageButton onUploaded={(images) => {
|
>
|
||||||
setImages((prev) => ([...prev, ...images]));
|
<MdAdd />
|
||||||
}}/>
|
{t("Add Alternative Title")}
|
||||||
</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>
|
</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>
|
||||||
</div>
|
</ImageDrapArea>
|
||||||
</ImageDrapArea>
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,12 @@
|
|||||||
import {FormEvent, useEffect, useState} from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import {network} from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import {app} from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import {useNavigate} from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import {useTranslation} from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {Turnstile} from "@marsidev/react-turnstile";
|
import { Turnstile } from "@marsidev/react-turnstile";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const {t} = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isLoading, setLoading] = useState(false);
|
const [isLoading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
@@ -37,7 +37,7 @@ export default function RegisterPage() {
|
|||||||
app.user = res.data!;
|
app.user = res.data!;
|
||||||
app.token = res.data!.token;
|
app.token = res.data!.token;
|
||||||
app.saveData();
|
app.saveData();
|
||||||
navigate("/", {replace: true});
|
navigate("/", { replace: true });
|
||||||
} else {
|
} else {
|
||||||
setError(res.message);
|
setError(res.message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -46,53 +46,87 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("Register");
|
document.title = t("Register");
|
||||||
}, [t])
|
}, [t]);
|
||||||
|
|
||||||
return <div className={"flex items-center justify-center w-full h-full bg-base-200"} id={"register-page"}>
|
return (
|
||||||
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
|
<div
|
||||||
<form onSubmit={onSubmit}>
|
className={"flex items-center justify-center w-full h-full bg-base-200"}
|
||||||
<div className={"card-body"}>
|
id={"register-page"}
|
||||||
<h1 className={"text-2xl font-bold"}>{t("Register")}</h1>
|
>
|
||||||
{error && <div role="alert" className="alert alert-error my-2">
|
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
<form onSubmit={onSubmit}>
|
||||||
viewBox="0 0 24 24">
|
<div className={"card-body"}>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<h1 className={"text-2xl font-bold"}>{t("Register")}</h1>
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
{error && (
|
||||||
</svg>
|
<div role="alert" className="alert alert-error my-2">
|
||||||
<span>{error}</span>
|
<svg
|
||||||
</div>}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<fieldset className="fieldset w-full">
|
className="h-6 w-6 shrink-0 stroke-current"
|
||||||
<legend className="fieldset-legend">{t("Username")}</legend>
|
fill="none"
|
||||||
<input type="text" className="input w-full" value={username} onChange={(e) => setUsername(e.target.value)}/>
|
viewBox="0 0 24 24"
|
||||||
</fieldset>
|
>
|
||||||
<fieldset className="fieldset w-full">
|
<path
|
||||||
<legend className="fieldset-legend">{t("Password")}</legend>
|
stroke-linecap="round"
|
||||||
<input type="password" className="input w-full" value={password}
|
stroke-linejoin="round"
|
||||||
onChange={(e) => setPassword(e.target.value)}/>
|
stroke-width="2"
|
||||||
</fieldset>
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
<fieldset className="fieldset w-full">
|
/>
|
||||||
<legend className="fieldset-legend">{t("Confirm Password")}</legend>
|
</svg>
|
||||||
<input type="password" className="input w-full" value={confirmPassword}
|
<span>{error}</span>
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}/>
|
</div>
|
||||||
</fieldset>
|
)}
|
||||||
{
|
<fieldset className="fieldset w-full">
|
||||||
app.cloudflareTurnstileSiteKey && <Turnstile
|
<legend className="fieldset-legend">{t("Username")}</legend>
|
||||||
siteKey={app.cloudflareTurnstileSiteKey}
|
<input
|
||||||
onSuccess={setCfToken}
|
type="text"
|
||||||
onExpire={() => setCfToken("")}
|
className="input w-full"
|
||||||
/>
|
value={username}
|
||||||
}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
<button className={"btn my-4 btn-primary"} type={"submit"}>
|
/>
|
||||||
{isLoading && <span className="loading loading-spinner"></span>}
|
</fieldset>
|
||||||
{t("Continue")}
|
<fieldset className="fieldset w-full">
|
||||||
</button>
|
<legend className="fieldset-legend">{t("Password")}</legend>
|
||||||
<button className="btn" type={"button"} onClick={() => {
|
<input
|
||||||
navigate("/login", {replace: true});
|
type="password"
|
||||||
}}>
|
className="input w-full"
|
||||||
{t("Already have an account? Login")}
|
value={password}
|
||||||
</button>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
</div>
|
/>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
);
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,35 @@
|
|||||||
import {useSearchParams} from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import {network} from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import ResourcesView from "../components/resources_view.tsx";
|
import ResourcesView from "../components/resources_view.tsx";
|
||||||
import {useEffect} from "react";
|
import { useEffect } from "react";
|
||||||
import {useTranslation} from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [params, _] = useSearchParams()
|
const [params, _] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const keyword = params.get("keyword")
|
const keyword = params.get("keyword");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("Search") + ": " + (keyword || "");
|
document.title = t("Search") + ": " + (keyword || "");
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
if (keyword === null || keyword === "") {
|
if (keyword === null || keyword === "") {
|
||||||
return <div role="alert" className="alert alert-info alert-dash">
|
return (
|
||||||
<span>{t("Enter a search keyword to continue")}</span>
|
<div role="alert" className="alert alert-info alert-dash">
|
||||||
</div>
|
<span>{t("Enter a search keyword to continue")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div key={keyword}>
|
return (
|
||||||
<h1 className={"text-2xl px-4 pt-4 font-bold my-2"}>{t("Search")}: {keyword}</h1>
|
<div key={keyword}>
|
||||||
<ResourcesView loader={(page) => network.searchResources(keyword, page)}></ResourcesView>
|
<h1 className={"text-2xl px-4 pt-4 font-bold my-2"}>
|
||||||
</div>
|
{t("Search")}: {keyword}
|
||||||
}
|
</h1>
|
||||||
|
<ResourcesView
|
||||||
|
loader={(page) => network.searchResources(keyword, page)}
|
||||||
|
></ResourcesView>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -2,18 +2,18 @@ import { useParams } from "react-router";
|
|||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import ResourcesView from "../components/resources_view.tsx";
|
import ResourcesView from "../components/resources_view.tsx";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {Tag} from "../network/models.ts";
|
import { Tag } from "../network/models.ts";
|
||||||
import Button from "../components/button.tsx";
|
import Button from "../components/button.tsx";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import {app} from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import Input, {TextArea} from "../components/input.tsx";
|
import Input, { TextArea } from "../components/input.tsx";
|
||||||
import TagInput from "../components/tag_input.tsx";
|
import TagInput from "../components/tag_input.tsx";
|
||||||
import Badge from "../components/badge.tsx";
|
import Badge from "../components/badge.tsx";
|
||||||
|
|
||||||
export default function TaggedResourcesPage() {
|
export default function TaggedResourcesPage() {
|
||||||
const { tag: tagName } = useParams()
|
const { tag: tagName } = useParams();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export default function TaggedResourcesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("Tag: ") + tagName;
|
document.title = t("Tag: ") + tagName;
|
||||||
}, [t, tagName])
|
}, [t, tagName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tagName) {
|
if (!tagName) {
|
||||||
@@ -35,44 +35,59 @@ export default function TaggedResourcesPage() {
|
|||||||
}, [tagName]);
|
}, [tagName]);
|
||||||
|
|
||||||
if (!tagName) {
|
if (!tagName) {
|
||||||
return <div className={"m-4"}>
|
return (
|
||||||
<ErrorAlert message={t("Tag not found")} />
|
<div className={"m-4"}>
|
||||||
</div>
|
<ErrorAlert message={t("Tag not found")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div>
|
return (
|
||||||
<div className={"flex items-center"}>
|
<div>
|
||||||
<h1 className={"text-2xl pt-6 pb-2 px-4 font-bold flex-1"}>
|
<div className={"flex items-center"}>
|
||||||
{tag?.name ?? tagName}
|
<h1 className={"text-2xl pt-6 pb-2 px-4 font-bold flex-1"}>
|
||||||
</h1>
|
{tag?.name ?? tagName}
|
||||||
{
|
</h1>
|
||||||
(tag && app.canUpload()) && <EditTagButton tag={tag} onEdited={(t) => {
|
{tag && app.canUpload() && (
|
||||||
setTag(t)
|
<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>
|
</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 [description, setDescription] = useState(tag.description);
|
||||||
const [isAlias, setIsAlias] = useState(false);
|
const [isAlias, setIsAlias] = useState(false);
|
||||||
const [aliasOf, setAliasOf] = useState<Tag | null>(null);
|
const [aliasOf, setAliasOf] = useState<Tag | null>(null);
|
||||||
@@ -82,7 +97,7 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDescription(tag.description)
|
setDescription(tag.description);
|
||||||
}, [tag.description]);
|
}, [tag.description]);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@@ -92,10 +107,17 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }
|
|||||||
}
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await network.setTagInfo(tag.id, description, aliasOf?.id ?? null, type);
|
const res = await network.setTagInfo(
|
||||||
|
tag.id,
|
||||||
|
description,
|
||||||
|
aliasOf?.id ?? null,
|
||||||
|
type,
|
||||||
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement;
|
const dialog = document.getElementById(
|
||||||
|
"edit_tag_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
dialog.close();
|
dialog.close();
|
||||||
onEdited(res.data!);
|
onEdited(res.data!);
|
||||||
} else {
|
} else {
|
||||||
@@ -103,50 +125,87 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
<Button onClick={()=> {
|
<>
|
||||||
const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement;
|
<Button
|
||||||
dialog.showModal();
|
onClick={() => {
|
||||||
}}>{t("Edit")}</Button>
|
const dialog = document.getElementById(
|
||||||
<dialog id="edit_tag_dialog" className="modal">
|
"edit_tag_dialog",
|
||||||
<div className="modal-box" style={{
|
) as HTMLDialogElement;
|
||||||
overflowY: "initial"
|
dialog.showModal();
|
||||||
}}>
|
}}
|
||||||
<h3 className="font-bold text-lg">{t("Edit Tag")}</h3>
|
>
|
||||||
<div className={"flex py-3"}>
|
{t("Edit")}
|
||||||
<p className={"flex-1"}>The tag is an alias of another tag</p>
|
</Button>
|
||||||
<input type="checkbox" className="toggle toggle-primary" checked={isAlias} onChange={(e) => {
|
<dialog id="edit_tag_dialog" className="modal">
|
||||||
setIsAlias(e.target.checked);
|
<div
|
||||||
}}/>
|
className="modal-box"
|
||||||
</div>
|
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 ? (
|
||||||
isAlias ? <>
|
<>
|
||||||
{
|
{aliasOf && (
|
||||||
aliasOf && <div className={"py-2 border border-base-300 rounded-3xl mt-2 px-4 flex mb-4"}>
|
<div
|
||||||
|
className={
|
||||||
|
"py-2 border border-base-300 rounded-3xl mt-2 px-4 flex mb-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
<p className={"flex-1"}>Alias Of: </p>
|
<p className={"flex-1"}>Alias Of: </p>
|
||||||
<Badge>{aliasOf.name}</Badge>
|
<Badge>{aliasOf.name}</Badge>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
<TagInput mainTag={true} onAdd={(tag: Tag) => {
|
<TagInput
|
||||||
setAliasOf(tag);
|
mainTag={true}
|
||||||
}}/>
|
onAdd={(tag: Tag) => {
|
||||||
</> : <>
|
setAliasOf(tag);
|
||||||
<Input value={type} onChange={(e) => setType(e.target.value)} label={"Type"}/>
|
}}
|
||||||
<TextArea label={"Description"} value={description} onChange={(e) => setDescription(e.target.value)}/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
) : (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
label={"Type"}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
label={"Description"}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <ErrorAlert className={"mt-2"} message={error} />}
|
{error && <ErrorAlert className={"mt-2"} message={error} />}
|
||||||
<div className="modal-action">
|
<div className="modal-action">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<Button className="btn">{t("Close")}</Button>
|
<Button className="btn">{t("Close")}</Button>
|
||||||
</form>
|
</form>
|
||||||
<Button isLoading={isLoading} className={"btn-primary"} onClick={submit}>
|
<Button
|
||||||
{t("Save")}
|
isLoading={isLoading}
|
||||||
</Button>
|
className={"btn-primary"}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{t("Save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dialog>
|
||||||
</dialog>
|
</>
|
||||||
</>
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import {TagWithCount} from "../network/models.ts";
|
import { TagWithCount } from "../network/models.ts";
|
||||||
import {useEffect, useState} from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {network} from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import showToast from "../components/toast.ts";
|
import showToast from "../components/toast.ts";
|
||||||
import Loading from "../components/loading.tsx";
|
import Loading from "../components/loading.tsx";
|
||||||
import Badge from "../components/badge.tsx";
|
import Badge from "../components/badge.tsx";
|
||||||
import {useNavigate} from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
export default function TagsPage() {
|
export default function TagsPage() {
|
||||||
const [tags, setTags] = useState<TagWithCount[] | null>(null);
|
const [tags, setTags] = useState<TagWithCount[] | null>(null);
|
||||||
@@ -16,22 +16,22 @@ export default function TagsPage() {
|
|||||||
} else {
|
} else {
|
||||||
showToast({
|
showToast({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: res.message || "Failed to load tags"
|
message: res.message || "Failed to load tags",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (!tags) {
|
if (!tags) {
|
||||||
return <Loading/>
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagsMap = new Map<string, TagWithCount[]>();
|
const tagsMap = new Map<string, TagWithCount[]>();
|
||||||
|
|
||||||
for (const tag of tags || []) {
|
for (const tag of tags || []) {
|
||||||
const type = tag.type
|
const type = tag.type;
|
||||||
if (!tagsMap.has(type)) {
|
if (!tagsMap.has(type)) {
|
||||||
tagsMap.set(type, []);
|
tagsMap.set(type, []);
|
||||||
}
|
}
|
||||||
@@ -42,21 +42,30 @@ export default function TagsPage() {
|
|||||||
tags.sort((a, b) => b.resources_count - a.resources_count);
|
tags.sort((a, b) => b.resources_count - a.resources_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="flex flex-col gap-4 p-4">
|
return (
|
||||||
<h1 className={"text-2xl font-bold py-2"}>Tags</h1>
|
<div className="flex flex-col gap-4 p-4">
|
||||||
{Array.from(tagsMap.entries()).map(([type, tags]) => (
|
<h1 className={"text-2xl font-bold py-2"}>Tags</h1>
|
||||||
<div key={type} className="flex flex-col gap-2">
|
{Array.from(tagsMap.entries()).map(([type, tags]) => (
|
||||||
<h2 className="text-lg font-bold pl-1">{type == "" ? "Other" : type}</h2>
|
<div key={type} className="flex flex-col gap-2">
|
||||||
<p>
|
<h2 className="text-lg font-bold pl-1">
|
||||||
{tags.map(tag => (
|
{type == "" ? "Other" : type}
|
||||||
<Badge onClick={() => {
|
</h2>
|
||||||
navigate(`/tag/${tag.name}`);
|
<p>
|
||||||
}} key={tag.name} className={"m-1 cursor-pointer badge-soft badge-primary"}>
|
{tags.map((tag) => (
|
||||||
{tag.name + (tag.resources_count > 0 ? ` (${tag.resources_count})` : "")}
|
<Badge
|
||||||
</Badge>
|
onClick={() => {
|
||||||
))}
|
navigate(`/tag/${tag.name}`);
|
||||||
</p>
|
}}
|
||||||
</div>
|
key={tag.name}
|
||||||
))}
|
className={"m-1 cursor-pointer badge-soft badge-primary"}
|
||||||
</div>
|
>
|
||||||
}
|
{tag.name +
|
||||||
|
(tag.resources_count > 0 ? ` (${tag.resources_count})` : "")}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -23,7 +23,7 @@ export default function UserPage() {
|
|||||||
showToast({
|
showToast({
|
||||||
message: res.message,
|
message: res.message,
|
||||||
type: "error",
|
type: "error",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [username]);
|
}, [username]);
|
||||||
@@ -33,54 +33,83 @@ export default function UserPage() {
|
|||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <div className="w-full">
|
return (
|
||||||
<Loading />
|
<div className="w-full">
|
||||||
</div>;
|
<Loading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div>
|
return (
|
||||||
<UserCard user={user!} />
|
<div>
|
||||||
<div role="tablist" className="border-b border-base-300 mx-2 flex">
|
<UserCard user={user!} />
|
||||||
<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="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 === 1 ? "border-primary text-primary" : "text-base-content/80"}`} onClick={() => setPage(1)}>Comments</div>
|
<div
|
||||||
|
role="tab"
|
||||||
|
className={`text-sm py-2 px-4 cursor-pointer border-b-2 border-base-100 ${page === 0 ? "border-primary text-primary" : "text-base-content/80"} transition-all`}
|
||||||
|
onClick={() => setPage(0)}
|
||||||
|
>
|
||||||
|
Resources
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="tab"
|
||||||
|
className={`text-sm py-2 px-4 cursor-pointer border-b-2 border-base-100 ${page === 1 ? "border-primary text-primary" : "text-base-content/80"}`}
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
>
|
||||||
|
Comments
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
{page === 0 && <UserResources user={user} />}
|
||||||
|
{page === 1 && <UserComments user={user} />}
|
||||||
|
</div>
|
||||||
|
<div className="h-16"></div>
|
||||||
</div>
|
</div>
|
||||||
<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 }) {
|
function UserCard({ user }: { user: User }) {
|
||||||
return <div className={"flex m-4 items-center"}>
|
return (
|
||||||
<div className={"avatar py-2"}>
|
<div className={"flex m-4 items-center"}>
|
||||||
<div className="w-24 rounded-full ring-2 ring-offset-2 ring-primary ring-offset-base-100">
|
<div className={"avatar py-2"}>
|
||||||
<img src={network.getUserAvatar(user)} />
|
<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>
|
</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 }) {
|
function UserResources({ user }: { user: User }) {
|
||||||
return <ResourcesView loader={(page) => {
|
return (
|
||||||
return network.getResourcesByUser(user.username, page);
|
<ResourcesView
|
||||||
}}></ResourcesView>
|
loader={(page) => {
|
||||||
|
return network.getResourcesByUser(user.username, page);
|
||||||
|
}}
|
||||||
|
></ResourcesView>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserComments({ user }: { user: User }) {
|
function UserComments({ user }: { user: User }) {
|
||||||
@@ -88,18 +117,30 @@ function UserComments({ user }: { user: User }) {
|
|||||||
|
|
||||||
const [maxPage, setMaxPage] = useState(0);
|
const [maxPage, setMaxPage] = useState(0);
|
||||||
|
|
||||||
return <div className="px-2">
|
return (
|
||||||
<CommentsList username={user.username} page={page} maxPageCallback={setMaxPage} />
|
<div className="px-2">
|
||||||
{maxPage ? <div className={"w-full flex justify-center"}>
|
<CommentsList
|
||||||
<Pagination page={page} setPage={setPage} totalPages={maxPage} />
|
username={user.username}
|
||||||
</div> : null}
|
page={page}
|
||||||
</div>
|
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 }: {
|
function CommentsList({
|
||||||
username: string,
|
username,
|
||||||
page: number,
|
page,
|
||||||
maxPageCallback: (maxPage: number) => void
|
maxPageCallback,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
page: number;
|
||||||
|
maxPageCallback: (maxPage: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [comments, setComments] = useState<CommentWithResource[] | null>(null);
|
const [comments, setComments] = useState<CommentWithResource[] | null>(null);
|
||||||
|
|
||||||
@@ -118,43 +159,50 @@ function CommentsList({ username, page, maxPageCallback }: {
|
|||||||
}, [maxPageCallback, page, username]);
|
}, [maxPageCallback, page, username]);
|
||||||
|
|
||||||
if (comments == null) {
|
if (comments == null) {
|
||||||
return <div className={"w-full"}>
|
return (
|
||||||
<Loading />
|
<div className={"w-full"}>
|
||||||
</div>
|
<Loading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return (
|
||||||
{
|
<>
|
||||||
comments.map((comment) => {
|
{comments.map((comment) => {
|
||||||
return <CommentTile comment={comment} key={comment.id} />
|
return <CommentTile comment={comment} key={comment.id} />;
|
||||||
})
|
})}
|
||||||
}
|
</>
|
||||||
</>
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentTile({ comment }: { comment: CommentWithResource }) {
|
function CommentTile({ comment }: { comment: CommentWithResource }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return <div className={"card card-border border-base-300 p-2 my-3"}>
|
return (
|
||||||
<div className={"flex flex-row items-center my-1 mx-1"}>
|
<div className={"card card-border border-base-300 p-2 my-3"}>
|
||||||
<div className="avatar">
|
<div className={"flex flex-row items-center my-1 mx-1"}>
|
||||||
<div className="w-8 rounded-full">
|
<div className="avatar">
|
||||||
<img src={network.getUserAvatar(comment.user)} alt={"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>
|
</div>
|
||||||
<div className={"w-2"}></div>
|
<div className={"p-2"}>{comment.content}</div>
|
||||||
<div className={"text-sm font-bold"}>{comment.user.username}</div>
|
<a
|
||||||
<div className={"grow"}></div>
|
className="text-sm text-base-content/80 p-1 hover:text-primary cursor-pointer transition-all"
|
||||||
<div className={"text-sm text-gray-500"}>{new Date(comment.created_at).toLocaleString()}</div>
|
onClick={() => {
|
||||||
|
navigate("/resources/" + comment.resource.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdOutlineArrowRight className="inline-block mr-1 mb-0.5" size={18} />
|
||||||
|
{comment.resource.title}
|
||||||
|
</a>
|
||||||
</div>
|
</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>
|
|
||||||
}
|
|
||||||
|
@@ -1,16 +1,16 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss(),],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
"/api": {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
Reference in New Issue
Block a user