Initial commit

This commit is contained in:
2025-05-11 20:32:14 +08:00
commit d97247159f
80 changed files with 13013 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
frontend/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

42
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,42 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'off',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/no-explicit-any': [
'off',
{
ignoreRestArgs: true,
},
]
},
},
)

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6334
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
frontend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.5",
"axios": "^1.9.0",
"i18next": "^25.1.1",
"i18next-browser-languagedetector": "^8.1.0",
"masonic": "^4.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^15.5.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.5.3",
"tailwindcss": "^4.1.5"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"daisyui": "^5.0.35",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1"
}
}

39
frontend/src/app.ts Normal file
View File

@@ -0,0 +1,39 @@
import {User} from "./network/models.ts";
class App {
appName = "资源库"
user: User | null = null;
token: string | null = null;
constructor() {
this.init();
}
init() {
const userJson = localStorage.getItem("user");
const tokenJson = localStorage.getItem("token");
if (userJson) {
this.user = JSON.parse(userJson);
}
if (tokenJson) {
this.token = JSON.parse(tokenJson);
}
}
saveData() {
localStorage.setItem("user", JSON.stringify(this.user));
localStorage.setItem("token", JSON.stringify(this.token));
}
isAdmin() {
return this.user != null && this.user.is_admin;
}
isLoggedIn() {
return this.user != null && this.token != null;
}
}
export const app = new App();

28
frontend/src/app.tsx Normal file
View File

@@ -0,0 +1,28 @@
import {BrowserRouter, Route, Routes} from "react-router";
import LoginPage from "./pages/login_page.tsx";
import RegisterPage from "./pages/register_page.tsx";
import Navigator from "./components/navigator.tsx";
import HomePage from "./pages/home_page.tsx";
import PublishPage from "./pages/publish_page.tsx";
import SearchPage from "./pages/search_page.tsx";
import ResourcePage from "./pages/resource_details_page.tsx";
import "./i18n.ts"
import ManagePage from "./pages/manage_page.tsx";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path={"/login"} element={<LoginPage/>}/>
<Route path={"/register"} element={<RegisterPage/>}/>
<Route element={<Navigator/>}>
<Route path={"/"} element={<HomePage/>}/>
<Route path={"/publish"} element={<PublishPage/>} />
<Route path={"/search"} element={<SearchPage/>} />
<Route path={"/resources/:id"} element={<ResourcePage/>}/>
<Route path={"/manage"} element={<ManagePage/>}/>
</Route>
</Routes>
</BrowserRouter>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,191 @@
import {app} from "../app.ts";
import {network} from "../network/network.ts";
import {useNavigate, useOutlet} from "react-router";
import {useEffect, useState} from "react";
import {MdOutlinePerson, MdSearch, MdSettings} from "react-icons/md";
import { useTranslation } from "react-i18next";
export default function Navigator() {
const outlet = useOutlet()
const navigate = useNavigate()
return <>
<div className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10">
<div className={"flex-1 max-w-7xl mx-auto flex"}>
<div className="flex-1">
<button className="btn btn-ghost text-xl" onClick={() => {
navigate(`/`);
}}>{app.appName}</button>
</div>
<div className="flex gap-2">
<SearchBar/>
{
app.isAdmin() && <button className={"btn btn-circle btn-ghost"} onClick={() => {
navigate("/manage");
}}>
<MdSettings size={24}/>
</button>
}
{
app.isLoggedIn() ? <UserButton/> : <button className={"btn btn-primary btn-square btn-soft"} onClick={() => {
navigate("/login");
}}>
<MdOutlinePerson size={24}></MdOutlinePerson>
</button>
}
</div>
</div>
</div>
<div className={"max-w-7xl mx-auto pt-16"}>
{outlet}
</div>
</>
}
function UserButton() {
let avatar = "./avatar.png";
if (app.user) {
avatar = network.getUserAvatar(app.user)
}
const navigate = useNavigate()
const {t} = useTranslation()
return <>
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle avatar">
<div className="w-10 rounded-full">
<img
alt="Avatar"
src={avatar}/>
</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?.id}`)}>{t("My Profile")}</a></li>
<li><a onClick={() => {
navigate(`/publish`);
const menu = document.getElementById("navi_dropdown_menu") as HTMLUListElement;
menu.blur();
}}>{t("Publish")}</a></li>
<li><a onClick={() => {
const dialog = document.getElementById("confirm_logout") as HTMLDialogElement;
dialog.showModal();
}}>{t("Log out")}</a></li>
</ul>
</div>
<dialog id="confirm_logout" className="modal">
<div className="modal-box">
<h3 className="text-lg font-bold">{t("Log out")}</h3>
<p className="py-4">{t("Are you sure you want to log out?")}</p>
<div className="modal-action">
<form method="dialog">
<button className="btn">{t('Cancel')}</button>
<button className="btn btn-error mx-2" type={"button"} onClick={() => {
app.user = null;
app.token = null;
app.saveData();
navigate(`/login`, {replace: true});
}}>{t('Confirm')}
</button>
</form>
</div>
</div>
</dialog>
</>
}
function SearchBar() {
const [small, setSmall] = useState(window.innerWidth < 640);
const navigate = useNavigate();
const [search, setSearch] = useState("");
const {t} = useTranslation();
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 640) {
setSmall(true);
} else {
setSmall(false);
}
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const doSearch = () => {
if (search.length === 0) {
return;
}
const replace = window.location.pathname === "/search";
navigate(`/search?keyword=${search}`, {replace: replace});
}
const searchField = <label className={`input input-primary ${small ? "w-full": "w-64"}`}>
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<form className={"w-full"} onSubmit={(e) => {
e.preventDefault();
doSearch();
}}>
<input type="search" className={"w-full"} required placeholder={t("Search")} value={search} onChange={(e) => setSearch(e.target.value)}/>
</form>
</label>
if (small) {
return <>
<button className={"btn btn-circle btn-ghost"} onClick={() => {
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
dialog.showModal();
}}>
<MdSearch size={24}/>
</button>
<dialog id="search_dialog" className="modal">
<div className="modal-box">
<form method="dialog">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 className="text-lg font-bold">{t("Search")}</h3>
<div className={"h-4"}/>
{searchField}
<div className={"h-4"}/>
<div className={"flex flex-row-reverse"}>
<button className={"btn btn-primary"} onClick={() => {
if (search.length === 0) {
return;
}
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
dialog.close();
doSearch();
}}>
{t("Search")}
</button>
</div>
</div>
</dialog>
</>
}
return searchField
}

View File

@@ -0,0 +1,36 @@
import { ReactNode } from "react";
import { MdChevronLeft, MdChevronRight } from "react-icons/md";
export default function Pagination({ page, setPage, totalPages }: { page: number, setPage: (page: number) => void, totalPages: number }) {
const items: ReactNode[] = [];
if (page > 1) {
items.push(<button className="join-item btn" onClick={() => setPage(1)}>1</button>);
}
if (page - 2 > 1) {
items.push(<button className="join-item btn">...</button>);
}
if (page-1 > 1) {
items.push(<button className="join-item btn" onClick={() => setPage(page-1)}>{page-1}</button>);
}
items.push(<button className="join-item btn btn-active">{page}</button>);
if (page+1 < totalPages) {
items.push(<button className="join-item btn" onClick={() => setPage(page+1)}>{page+1}</button>);
}
if (page+2 < totalPages) {
items.push(<button className="join-item btn">...</button>);
}
if (page < totalPages) {
items.push(<button className="join-item btn" onClick={() => setPage(totalPages)}>{totalPages}</button>);
}
return <div className="join">
<button className={`join-item btn ${page === 1 && "btn-disabled"}`} onClick={() => setPage(page-1)}>
<MdChevronLeft size={20} className="opacity-50"/>
</button>
{items}
<button className={`join-item btn ${page === totalPages && "btn-disabled"}`} onClick={() => setPage(page+1)}>
<MdChevronRight size={20} className="opacity-50"/>
</button>
</div>
}

View File

@@ -0,0 +1,61 @@
import React from "react";
import { createRoot } from "react-dom/client";
export default function showPopup(content: React.ReactNode, element: HTMLElement) {
const eRect = element.getBoundingClientRect();
const div = document.createElement("div");
div.style.position = "fixed";
if (eRect.x > window.innerWidth / 2) {
div.style.right = `${window.innerWidth - eRect.x}px`;
} else {
div.style.left = `${eRect.x}px`;
}
if (eRect.y > window.innerHeight / 2) {
div.style.bottom = `${window.innerHeight - eRect.y}px`;
} else {
div.style.top = `${eRect.y}px`;
}
div.style.zIndex = "9999";
div.className = "animate-appearance-in";
document.body.appendChild(div);
const mask = document.createElement("div");
const close = () => {
console.log("close popup");
document.body.removeChild(div);
document.body.removeChild(mask);
};
mask.style.position = "fixed";
mask.style.top = "0";
mask.style.left = "0";
mask.style.width = "100%";
mask.style.height = "100%";
mask.style.zIndex = "9998";
mask.onclick = close;
document.body.appendChild(mask);
createRoot(div).render(<context.Provider value={close}>
{content}
</context.Provider>)
}
const context = React.createContext<() => void>(() => {});
export function useClosePopup() {
return React.useContext(context);
}
export function PopupMenuItem({ children, onClick }: { children: React.ReactNode, onClick: () => void }) {
const close = useClosePopup();
return <li onClick={() => {
close();
onClick();
}}>
{children}
</li>
}

View File

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

View File

@@ -0,0 +1,44 @@
import {PageResponse, Resource} from "../network/models.ts";
import {useCallback, useEffect, useRef, useState} from "react";
import showToast from "./toast.ts";
import ResourceCard from "./resource_card.tsx";
import {Masonry} from "masonic";
import Loading from "./loading.tsx";
export default function ResourcesView({loader}: {loader: (page: number) => Promise<PageResponse<Resource>>}) {
const [data, setData] = useState<Resource[]>([])
const pageRef = useRef(1)
const totalPagesRef = useRef(1)
const isLoadingRef = useRef(false)
const loadPage = useCallback(async () => {
if (pageRef.current > totalPagesRef.current) return
if (isLoadingRef.current) return
isLoadingRef.current = true
const res = await loader(pageRef.current)
if (!res.success) {
showToast({message: "Error loading resources", type: "error"})
} else {
isLoadingRef.current = false
pageRef.current = pageRef.current + 1
totalPagesRef.current = res.totalPages ?? 1
setData((prev) => [...prev, ...res.data!])
}
}, [loader])
useEffect(() => {
loadPage()
}, [loadPage]);
return <div className={"px-2 pt-2"}>
<Masonry columnWidth={300} items={data} render={(e) => {
if (e.index === data.length - 1) {
loadPage()
}
return <ResourceCard resource={e.data} key={e.data.id}/>
} }></Masonry>
{
pageRef.current <= totalPagesRef.current && <Loading/>
}
</div>
}

View File

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

275
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,275 @@
export const i18nData = {
"en": {
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, images, and files can be modified after publishing": "All information, images, and files can be modified after publishing",
"Title": "Title",
"Alternative Titles": "Alternative Titles",
"Add Alternative Title": "Add Alternative Title",
"Tags": "Tags",
"Description": "Description",
"Use Markdown format": "Use Markdown format",
"Images": "Images",
"Images will not be displayed automatically, you need to reference them in the description": "Images will not be displayed automatically, you need to reference them in the description",
"Preview": "Preview",
"Link": "Link",
"Action": "Action",
"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",
// 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
"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",
"Path": "Path",
"Max Size (MB)": "Max Size (MB)",
"Endpoint": "Endpoint",
"Access Key ID": "Access Key ID",
"Secret Access Key": "Secret Access Key",
"Bucket Name": "Bucket Name",
"All fields are required": "All fields are required",
"Storage created successfully": "Storage created successfully",
"Close": "Close",
"Submit": "Submit",
// 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.",
"User deleted successfully": "User deleted successfully",
"Set as user": "Set as user",
"Set as admin": "Set as admin",
"Remove upload permission": "Remove upload permission",
"Grant upload permission": "Grant upload permission",
"User set as admin successfully": "User set as admin successfully",
"User set as user successfully": "User set as user successfully",
"User set as upload permission successfully": "User set as upload permission successfully",
"User removed upload permission successfully": "User removed upload permission successfully",
}
},
"zh-CN": {
translation: {
"My Profile": "我的资料",
"Publish": "发布",
"Log out": "退出登录",
"Are you sure you want to log out?": "您确定要退出登录吗?",
"Cancel": "取消",
"Confirm": "确认",
"Search": "搜索",
"Login": "登录",
"Register": "注册",
"Username": "用户名",
"Password": "密码",
"Confirm Password": "确认密码",
"Username and password cannot be empty": "用户名和密码不能为空",
"Passwords do not match": "两次输入的密码不匹配",
"Continue": "继续",
"Don't have an account? Register": "没有账号?注册",
"Already have an account? Login": "已有账号?登录",
"Publish Resource": "发布资源",
"All information, images, and files can be modified after publishing": "所有的信息, 图片, 文件均可在发布后修改",
"Title": "标题",
"Alternative Titles": "其他标题",
"Add Alternative Title": "新增标题",
"Tags": "标签",
"Description": "介绍",
"Use Markdown format": "使用Markdown格式",
"Images": "图片",
"Images will not be displayed automatically, you need to reference them in the description": "图片不会被自动显示, 你需要在介绍中引用它们",
"Preview": "预览",
"Link": "链接",
"Action": "操作",
"Upload Image": "上传图片",
"Error": "错误",
"Title cannot be empty": "标题不能为空",
"Alternative title cannot be empty": "不能存在空标题",
"At least one tag required": "至少选择一个标签",
"Description cannot be empty": "介绍不能为空",
"Loading": "加载中",
"Enter a search keyword to continue": "输入搜索关键词以继续",
// Management page translations
"Manage": "管理",
"Storage": "存储",
"Users": "用户",
"You are not logged in. Please log in to access this page.": "您尚未登录。请登录以访问此页面。",
"You are not authorized to access this page.": "您无权访问此页面。",
// Storage management
"No storage found. Please create a new storage.": "未找到存储。请创建新的存储。",
"Name": "名称",
"Created At": "创建于",
"Actions": "操作",
"Delete Storage": "删除存储",
"Are you sure you want to delete this storage? This action cannot be undone.": "您确定要删除此存储吗?此操作不可撤销。",
"Delete": "删除",
"Storage deleted successfully": "存储已成功删除",
"New Storage": "新建存储",
"Type": "类型",
"Local": "本地",
"S3": "S3",
"Path": "路径",
"Max Size (MB)": "最大大小 (MB)",
"Endpoint": "终端节点",
"Access Key ID": "访问密钥 ID",
"Secret Access Key": "私有访问密钥",
"Bucket Name": "桶名称",
"All fields are required": "所有字段都是必填的",
"Storage created successfully": "存储创建成功",
"Close": "关闭",
"Submit": "提交",
// User management
"Admin": "管理员",
"Can Upload": "可上传",
"Yes": "是",
"No": "否",
"Delete User": "删除用户",
"Are you sure you want to delete user": "您确定要删除用户",
"This action cannot be undone.": "此操作不可撤销。",
"User deleted successfully": "用户已成功删除",
"Set as user": "设为普通用户",
"Set as admin": "设为管理员",
"Remove upload permission": "移除上传权限",
"Grant upload permission": "授予上传权限",
"User set as admin successfully": "用户已成功设为管理员",
"User set as user successfully": "用户已成功设为普通用户",
"User set as upload permission successfully": "用户已成功授予上传权限",
"User removed upload permission successfully": "用户已成功移除上传权限",
}
},
"zh-TW": {
translation: {
"My Profile": "我的資料",
"Publish": "發布",
"Log out": "登出",
"Are you sure you want to log out?": "您確定要登出嗎?",
"Cancel": "取消",
"Confirm": "確認",
"Search": "搜尋",
"Login": "登入",
"Register": "註冊",
"Username": "用戶名",
"Password": "密碼",
"Confirm Password": "確認密碼",
"Username and password cannot be empty": "用戶名和密碼不能為空",
"Passwords do not match": "兩次輸入的密碼不匹配",
"Continue": "繼續",
"Don't have an account? Register": "沒有賬號?註冊",
"Already have an account? Login": "已有賬號?登入",
"Publish Resource": "發布資源",
"All information, images, and files can be modified after publishing": "所有資訊、圖片、檔案均可於發布後修改",
"Title": "標題",
"Alternative Titles": "其他標題",
"Add Alternative Title": "新增標題",
"Tags": "標籤",
"Description": "介紹",
"Use Markdown format": "使用Markdown格式",
"Images": "圖片",
"Images will not be displayed automatically, you need to reference them in the description": "圖片不會自動顯示,需在介紹中引用",
"Preview": "預覽",
"Link": "連結",
"Action": "操作",
"Upload Image": "上傳圖片",
"Error": "錯誤",
"Title cannot be empty": "標題不能為空",
"Alternative title cannot be empty": "不能有空的標題",
"At least one tag required": "至少選擇一個標籤",
"Description cannot be empty": "介紹不能為空",
"Loading": "載入中",
"Enter a search keyword to continue": "輸入搜尋關鍵字以繼續",
// Management page translations
"Manage": "管理",
"Storage": "儲存",
"Users": "用戶",
"You are not logged in. Please log in to access this page.": "您尚未登入。請登入以訪問此頁面。",
"You are not authorized to access this page.": "您無權訪問此頁面。",
// Storage management
"No storage found. Please create a new storage.": "未找到儲存。請創建新的儲存。",
"Name": "名稱",
"Created At": "建立於",
"Actions": "操作",
"Delete Storage": "刪除儲存",
"Are you sure you want to delete this storage? This action cannot be undone.": "您確定要刪除此儲存嗎?此操作不可撤銷。",
"Delete": "刪除",
"Storage deleted successfully": "儲存已成功刪除",
"New Storage": "新建儲存",
"Type": "類型",
"Local": "本地",
"S3": "S3",
"Path": "路徑",
"Max Size (MB)": "最大大小 (MB)",
"Endpoint": "端點",
"Access Key ID": "訪問密鑰 ID",
"Secret Access Key": "私有訪問密鑰",
"Bucket Name": "儲存桶名稱",
"All fields are required": "所有欄位都是必填的",
"Storage created successfully": "儲存創建成功",
"Close": "關閉",
"Submit": "提交",
// User management
"Admin": "管理員",
"Can Upload": "可上傳",
"Yes": "是",
"No": "否",
"Delete User": "刪除用戶",
"Are you sure you want to delete user": "您確定要刪除用戶",
"This action cannot be undone.": "此操作不可撤銷。",
"User deleted successfully": "用戶已成功刪除",
"Set as user": "設為普通用戶",
"Set as admin": "設為管理員",
"Remove upload permission": "移除上傳權限",
"Grant upload permission": "授予上傳權限",
"User set as admin successfully": "用戶已成功設為管理員",
"User set as user successfully": "用戶已成功設為普通用戶",
"User set as upload permission successfully": "用戶已成功授予上傳權限",
"User removed upload permission successfully": "用戶已成功移除上傳權限",
}
}
}

113
frontend/src/index.css Normal file
View File

@@ -0,0 +1,113 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "light";
default: false;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(97% 0 0);
--color-base-300: oklch(92% 0 0);
--color-base-content: oklch(20% 0 0);
--color-primary: oklch(80% 0.114 19.571);
--color-primary-content: oklch(25% 0.092 26.042);
--color-secondary: oklch(81% 0.111 293.571);
--color-secondary-content: oklch(28% 0.141 291.089);
--color-accent: oklch(90% 0.182 98.111);
--color-accent-content: oklch(28% 0.066 53.813);
--color-neutral: oklch(14% 0 0);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(70% 0.165 254.624);
--color-info-content: oklch(28% 0.091 267.935);
--color-success: oklch(84% 0.238 128.85);
--color-success-content: oklch(27% 0.072 132.109);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(27% 0.077 45.635);
--color-error: oklch(71% 0.194 13.428);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 0.25rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 1;
}
@plugin "daisyui/theme" {
name: "valentine";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0.014 343.198);
--color-base-200: oklch(96% 0.028 342.258);
--color-base-300: oklch(84% 0.061 343.231);
--color-base-content: oklch(0% 0 0);
--color-primary: oklch(65% 0.241 354.308);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(62% 0.265 303.9);
--color-secondary-content: oklch(97% 0.014 308.299);
--color-accent: oklch(78% 0.115 274.713);
--color-accent-content: oklch(39% 0.09 240.876);
--color-neutral: oklch(40% 0.153 2.432);
--color-neutral-content: oklch(89% 0.061 343.231);
--color-info: oklch(80% 0.105 251.813);
--color-info-content: oklch(44% 0.11 240.79);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(43% 0.095 166.913);
--color-warning: oklch(75% 0.183 55.934);
--color-warning-content: oklch(26% 0.079 36.259);
--color-error: oklch(63% 0.237 25.331);
--color-error-content: oklch(97% 0.013 17.38);
--radius-selector: 1rem;
--radius-field: 2rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
html {
width: 100%;
height: 100%;
overflow-y: scroll;
}
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
@keyframes appearance-in {
0% {
opacity: 0;
transform: translateZ(0) scale(0.95);
}
60% {
opacity: 0.75;
backface-visibility: hidden;
webkit-font-smoothing: antialiased;
transform: translateZ(0) scale(1.05);
}
100% {
opacity: 1;
transform: translateZ(0) scale(1);
}
}
.animate-appearance-in {
animation: appearance-in 250ms ease-out normal both;
}

26
frontend/src/main.tsx Normal file
View File

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

81
frontend/src/markdown.css Normal file
View File

@@ -0,0 +1,81 @@
article {
h1 {
font-size: 20px;
font-weight: bold;
padding: 12px 0;
}
h2 {
font-size: 18px;
font-weight: bold;
padding: 10px 0;
}
h3 {
font-size: 16px;
font-weight: bold;
padding: 8px 0;
}
h4 {
font-size: 14px;
font-weight: bold;
padding: 6px 0;
}
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.5;
margin: 8px 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;
}
}

View File

@@ -0,0 +1,91 @@
export interface User {
id: number;
username: string;
created_at: string;
avatar_path: string;
is_admin: boolean;
can_upload: boolean;
}
export interface UserWithToken extends User {
token: string;
}
export interface Response<T> {
success: boolean;
message: string;
data?: T;
}
export interface PageResponse<T> {
success: boolean;
message: string;
data?: T[];
totalPages?: number;
}
export interface Tag {
id: number;
name: string;
}
export interface CreateResourceParams {
title: string;
alternative_titles: string[];
tags: number[];
article: string;
images: number[];
}
export interface Image {
id: number;
width: number;
height: number;
}
export interface Resource {
id: number;
title: string;
created_at: string;
tags: Tag[];
image?: Image;
author: User;
}
export interface ResourceDetails {
id: number;
title: string;
alternativeTitles: string[];
article: string;
createdAt: string;
tags: Tag[];
images: Image[];
files: RFile[];
author: User;
}
export interface Storage {
id: number;
name: string;
type: string;
maxSize: number;
currentSize: number;
createdAt: string;
}
export interface RFile {
id: number;
filename: string;
description: string;
}
export interface UploadingFile {
id: number;
filename: string;
description: string;
totalSize?: number;
blockSize: number;
blocksCount: number;
storageId: number;
resourceId: number;
}

View File

@@ -0,0 +1,525 @@
import axios from 'axios';
import {app} from "../app.ts";
import {
CreateResourceParams,
RFile,
PageResponse,
Resource,
ResourceDetails,
Response,
Storage,
Tag,
UploadingFile,
User,
UserWithToken
} from "./models.ts";
class Network {
baseUrl = ''
apiBaseUrl = '/api'
constructor() {
this.init()
}
init() {
if (import.meta.env.MODE === 'development') {
this.baseUrl = 'http://localhost:3000';
this.apiBaseUrl = 'http://localhost:3000/api';
}
axios.defaults.validateStatus = _ => true
axios.interceptors.request.use((config) => {
if (app.token) {
config.headers['Authorization'] = app.token;
}
return config
})
axios.interceptors.response.use(
(response) => {
if (response.status >= 400 && response.status < 500) {
const data = response.data;
if (data.message) {
throw new Error(data.message);
} else {
throw new Error(`Invalid response: ${response.status}`);
}
} else if (response.status >= 500) {
throw new Error(`Server error: ${response.status}`);
} else {
return response
}
},
(error) => {
return Promise.reject(error);
})
}
async login(username: string, password: string): Promise<Response<UserWithToken>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/login`, {
username,
password
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async register(username: string, password: string): Promise<Response<UserWithToken>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/register`, {
username,
password
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async changePassword(oldPassword: string, newPassword: string): Promise<Response<UserWithToken>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/password`, {
old_password: oldPassword,
new_password: newPassword
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async changeAvatar(file: File): Promise<Response<User>> {
try {
const formData = new FormData();
formData.append('avatar', file);
const response = await axios.put(`${this.apiBaseUrl}/user/avatar`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
getUserAvatar(user: User): string {
return this.baseUrl + user.avatar_path
}
async setUserAdmin(userId: number, isAdmin: boolean): Promise<Response<User>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/set_admin`, {
user_id: userId,
is_admin: isAdmin ? 'true' : 'false'
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async setUserUploadPermission(userId: number, canUpload: boolean): Promise<Response<User>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/set_upload_permission`, {
user_id: userId,
can_upload: canUpload ? 'true' : 'false'
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async listUsers(page: number): Promise<PageResponse<User>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/user/list`, {
params: { page }
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async searchUsers(username: string, page: number): Promise<PageResponse<User>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/user/search`, {
params: {
username,
page
}
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async deleteUser(userId: number): Promise<Response<void>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/delete`, {
user_id: userId
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async searchTags(keyword: string): Promise<Response<Tag[]>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/tag/search`, {
params: {
keyword
}
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async createTag(name: string): Promise<Response<Tag>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/tag`, {
name
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
/**
* Upload image and return the image id
*/
async uploadImage(file: File): Promise<Response<number>> {
try {
const data = await file.arrayBuffer()
const response = await axios.put(`${this.apiBaseUrl}/image`, data, {
headers: {
'Content-Type': 'application/octet-stream',
},
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async deleteImage(id: number): Promise<Response<void>> {
try {
const response = await axios.delete(`${this.apiBaseUrl}/image/${id}`)
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
getImageUrl(id: number): string {
return `${this.apiBaseUrl}/image/${id}`
}
async createResource(params: CreateResourceParams): Promise<Response<number>> {
console.log(this)
try {
const response = await axios.post(`${this.apiBaseUrl}/resource`, params)
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async getResources(page: number): Promise<PageResponse<Resource>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/resource`, {
params: {
page
}
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async searchResources(keyword: string, page: number): Promise<PageResponse<Resource>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/resource/search`, {
params: {
keyword,
page
}
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async getResourceDetails(id: number): Promise<Response<ResourceDetails>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/resource/${id}`)
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async createS3Storage(name: string, endPoint: string, accessKeyID: string,
secretAccessKey: string, bucketName: string, maxSizeInMB: number): Promise<Response<any>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/storage/s3`, {
name,
endPoint,
accessKeyID,
secretAccessKey,
bucketName,
maxSizeInMB
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async createLocalStorage(name: string, path: string, maxSizeInMB: number): Promise<Response<any>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/storage/local`, {
name,
path,
maxSizeInMB
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async listStorages(): Promise<Response<Storage[]>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/storage`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async deleteStorage(id: number): Promise<Response<void>> {
try {
const response = await axios.delete(`${this.apiBaseUrl}/storage/${id}`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async initFileUpload(filename: string, description: string, fileSize: number,
resourceId: number, storageId: number): Promise<Response<UploadingFile>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/files/upload/init`, {
filename,
description,
file_size: fileSize,
resource_id: resourceId,
storage_id: storageId
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async uploadFileBlock(fileId: number, index: number, blockData: Blob): Promise<Response<any>> {
try {
const formData = new FormData();
formData.append('block', blockData);
const response = await axios.post(
`${this.apiBaseUrl}/files/upload/block/${fileId}/${index}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async finishFileUpload(fileId: number): Promise<Response<RFile>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/files/upload/finish/${fileId}`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async createRedirectFile(filename: string, description: string,
resourceId: number, redirectUrl: string): Promise<Response<RFile>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/files/redirect`, {
filename,
description,
resource_id: resourceId,
redirect_url: redirectUrl
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async getFile(fileId: number): Promise<Response<RFile>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/files/${fileId}`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async updateFile(fileId: number, filename: string, description: string): Promise<Response<RFile>> {
try {
const response = await axios.put(`${this.apiBaseUrl}/files/${fileId}`, {
filename,
description
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async deleteFile(fileId: number): Promise<Response<void>> {
try {
const response = await axios.delete(`${this.apiBaseUrl}/files/${fileId}`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
}
export const network = new Network();

View File

@@ -0,0 +1,6 @@
import ResourcesView from "../components/resources_view.tsx";
import {network} from "../network/network.ts";
export default function HomePage() {
return <ResourcesView loader={(page) => network.getResources(page)}></ResourcesView>
}

View File

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

View File

@@ -0,0 +1,87 @@
import {app} from "../app.ts";
import {MdMenu, MdOutlinePerson, MdOutlineStorage} from "react-icons/md";
import {ReactNode, useEffect, useState} from "react";
import StorageView from "./manage_storage_page.tsx";
import UserView from "./manage_user_page.tsx";
import { useTranslation } from "react-i18next";
import {ErrorAlert} from "../components/alert.tsx";
export default function ManagePage() {
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [lg, setLg] = useState(window.innerWidth >= 1024);
useEffect(() => {
const handleResize = () => {
setLg(window.innerWidth >= 1024);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
if (!app.user) {
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")}/>
}
if (!app.user?.is_admin) {
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")}/>
}
const buildItem = (title: string, icon: ReactNode, p: number) => {
return <li key={title} onClick={() => setPage(p)} className={"my-1"}>
<a className={`flex items-center h-9 px-4 ${page == p && "bg-primary text-primary-content"}`}>
{icon}
<span className={"text"}>
{title}
</span>
</a>
</li>
}
const pageNames = [
t("Storage"),
t("Users")
]
const pageComponents = [
<StorageView/>,
<UserView/>
]
return <div className="drawer lg:drawer-open">
<input id="my-drawer-2" type="checkbox" className="drawer-toggle"/>
<div className="drawer-content" style={{
height: "calc(100vh - 64px)",
}}>
<div className={"flex w-full h-14 items-center gap-2 px-3"}>
<label className={"btn btn-square btn-ghost lg:hidden"} htmlFor="my-drawer-2">
<MdMenu size={24}/>
</label>
<h1 className={"text-xl font-bold"}>
{pageNames[page]}
</h1>
</div>
<div>
{pageComponents[page]}
</div>
</div>
<div 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("Storage"), <MdOutlineStorage className={"text-xl"}/>, 0)}
{buildItem(t("Users"), <MdOutlinePerson className={"text-xl"}/>, 1)}
</ul>
</div>
</div>
}

View File

@@ -0,0 +1,336 @@
import {useEffect, useState} from "react";
import {Storage} from "../network/models.ts";
import {network} from "../network/network.ts";
import showToast from "../components/toast.ts";
import Loading from "../components/loading.tsx";
import {MdAdd, MdDelete} from "react-icons/md";
import {ErrorAlert} from "../components/alert.tsx";
import { useTranslation } from "react-i18next";
export default function StorageView() {
const { t } = useTranslation();
const [storages, setStorages] = useState<Storage[] | null>(null);
const [loadingId, setLoadingId] = useState<number | null>(null);
useEffect(() => {
network.listStorages().then((response) => {
if (response.success) {
setStorages(response.data!);
} else {
showToast({
message: response.message,
type: "error"
});
}
})
}, []);
if (storages == null) {
return <Loading/>
}
const updateStorages = async () => {
setStorages(null)
const response = await network.listStorages();
if (response.success) {
setStorages(response.data!);
} else {
showToast({
message: response.message,
type: "error"
});
}
}
const handleDelete = async (id: number) => {
if (loadingId != null) {
return;
}
setLoadingId(id);
const response = await network.deleteStorage(id);
if (response.success) {
showToast({
message: t("Storage deleted successfully"),
});
updateStorages();
} else {
showToast({
message: response.message,
type: "error"
});
}
setLoadingId(null);
}
return <>
<div role="alert" className={`alert alert-info alert-outline ${storages.length !== 0 && "hidden"} mx-4 mb-4`}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
className="h-6 w-6 shrink-0 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>
{t("No storage found. Please create a new storage.")}
</span>
</div>
<div className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto ${storages.length === 0 ? "hidden" : ""}`}>
<table className={"table"}>
<thead>
<tr>
<td>{t("Name")}</td>
<td>{t("Created At")}</td>
<td>{t("Action")}</td>
</tr>
</thead>
<tbody>
{
storages.map((s) => {
return <tr key={s.id} className={"hover"}>
<td>
{s.name}
</td>
<td>
{(new Date(s.createdAt)).toLocaleString()}
</td>
<td>
<button className={"btn btn-square"} type={"button"} onClick={() => {
const dialog = document.getElementById(`confirm_delete_dialog_${s.id}`) as HTMLDialogElement;
dialog.showModal();
}}>
{loadingId === s.id ? <span className={"loading loading-spinner loading-sm"}></span> : <MdDelete size={24}/>}
</button>
<dialog id={`confirm_delete_dialog_${s.id}`} className="modal">
<div className="modal-box">
<h3 className="text-lg font-bold">{t("Delete Storage")}</h3>
<p className="py-4">
{t("Are you sure you want to delete this storage? This action cannot be undone.")}
</p>
<div className="modal-action">
<form method="dialog">
<button className="btn">{t("Cancel")}</button>
</form>
<button className="btn btn-error" onClick={() => {
handleDelete(s.id);
}}>
{t("Delete")}
</button>
</div>
</div>
</dialog>
</td>
</tr>
})
}
</tbody>
</table>
</div>
<div className={"flex flex-row-reverse px-4"}>
<NewStorageDialog onAdded={updateStorages}/>
</div>
</>
}
enum StorageType {
local,
s3,
}
function NewStorageDialog({onAdded}: { onAdded: () => void }) {
const { t } = useTranslation();
const [storageType, setStorageType] = useState<StorageType | null>(null);
const [params, setParams] = useState({
name: "",
path: "",
endPoint: "",
accessKeyID: "",
secretAccessKey: "",
bucketName: "",
maxSizeInMB: 0,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
if (storageType == null) {
return;
}
setIsLoading(true);
let response;
if (storageType === StorageType.local) {
if (params.path === "" || params.name === "" || params.maxSizeInMB <= 0) {
setError(t("All fields are required"));
setIsLoading(false);
return;
}
response = await network.createLocalStorage(params.name, params.path, params.maxSizeInMB);
} else if (storageType === StorageType.s3) {
if (params.endPoint === "" || params.accessKeyID === "" || params.secretAccessKey === "" || params.bucketName === "" || params.name === "" || params.maxSizeInMB <= 0) {
setError(t("All fields are required"));
setIsLoading(false);
return;
}
response = await network.createS3Storage(params.name, params.endPoint, params.accessKeyID, params.secretAccessKey, params.bucketName, params.maxSizeInMB);
}
if (response!.success) {
showToast({
message: t("Storage created successfully"),
});
onAdded();
const dialog = document.getElementById("new_storage_dialog") as HTMLDialogElement;
dialog.close();
} else {
setError(response!.message);
}
setIsLoading(false);
}
return <>
<button className="btn" onClick={()=> {
const dialog = document.getElementById("new_storage_dialog") as HTMLDialogElement;
dialog.showModal();
}}>
<MdAdd/>
{t("New Storage")}
</button>
<dialog id="new_storage_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg pb-4">{t("New Storage")}</h3>
<p className={"text-sm font-bold p-2"}>{t("Type")}</p>
<form className="filter mb-2">
<input className="btn btn-square" type="reset" value="×" onClick={() => {
setStorageType(null);
}}/>
<input className="btn" type="radio" name="type" aria-label={t("Local")} onInput={() => {
setStorageType(StorageType.local);
}}/>
<input className="btn" type="radio" name="type" aria-label={t("S3")} onInput={() => {
setStorageType(StorageType.s3);
}}/>
</form>
{
storageType === StorageType.local && <>
<label className="input w-full my-2">
{t("Name")}
<input type="text" className="w-full" value={params.name} onChange={(e) => {
setParams({
...params,
name: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Path")}
<input type="text" className="w-full" value={params.path} onChange={(e) => {
setParams({
...params,
path: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Max Size (MB)")}
<input
type="number"
className="validator"
required
min="0"
value={params.maxSizeInMB.toString()}
onChange={(e) => {
setParams({
...params,
maxSizeInMB: parseInt(e.target.value),
})
}}
/>
</label>
</>
}
{
storageType === StorageType.s3 && <>
<label className="input w-full my-2">
{t("Name")}
<input type="text" className="w-full" value={params.name} onChange={(e) => {
setParams({
...params,
name: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Endpoint")}
<input type="text" className="w-full" value={params.endPoint} onChange={(e) => {
setParams({
...params,
endPoint: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Access Key ID")}
<input type="text" className="w-full" value={params.accessKeyID} onChange={(e) => {
setParams({
...params,
accessKeyID: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Secret Access Key")}
<input type="text" className="w-full" value={params.secretAccessKey} onChange={(e) => {
setParams({
...params,
secretAccessKey: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Bucket Name")}
<input type="text" className="w-full" value={params.bucketName} onChange={(e) => {
setParams({
...params,
bucketName: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("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>
</dialog>
</>
}

View File

@@ -0,0 +1,267 @@
import { createRef, useCallback, useEffect, useState } from "react";
import { User } from "../network/models";
import { network } from "../network/network";
import showToast from "../components/toast";
import Loading from "../components/loading";
import { MdMoreHoriz, MdSearch } from "react-icons/md";
import Pagination from "../components/pagination";
import showPopup, { PopupMenuItem } from "../components/popup";
import { useTranslation } from "react-i18next";
export default function UserView() {
const { t } = useTranslation();
const [searchKeyword, setSearchKeyword] = useState("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
return <>
<div className={"flex flex-row justify-between items-center mx-4 my-4"}>
<form className={"flex flex-row gap-2 items-center w-64"} onSubmit={(e) => {
e.preventDefault();
setPage(0);
const input = e.currentTarget.querySelector("input[type=search]") as HTMLInputElement;
setSearchKeyword(input.value);
}}>
<label className="input">
<MdSearch size={20} className="opacity-50" />
<input type="search" className="grow" placeholder={t("Search")} id="search" />
</label>
</form>
</div>
<UserTable page={page} searchKeyword={searchKeyword} key={`${page}&${searchKeyword}`} totalPagesCallback={setTotalPages} />
<div className={"flex flex-row justify-center items-center my-4"}>
{totalPages ? <Pagination page={page} setPage={setPage} totalPages={totalPages} /> : null}
</div>
</>
}
function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number, searchKeyword: string, totalPagesCallback: (totalPages: number) => void }) {
const { t } = useTranslation();
const [users, setUsers] = useState<User[] | null>(null);
const fetchUsers = useCallback(() => {
if (searchKeyword) {
network.searchUsers(searchKeyword, page).then((response) => {
if (response.success) {
setUsers(response.data!);
totalPagesCallback(response.totalPages!);
} else {
showToast({
type: "error",
message: response.message,
})
}
});
} else {
network.listUsers(page).then((response) => {
if (response.success) {
setUsers(response.data!);
totalPagesCallback(response.totalPages!);
} else {
showToast({
type: "error",
message: response.message,
})
}
});
}
}, [page, searchKeyword, totalPagesCallback]);
useEffect(() => {
fetchUsers();
}, [page, searchKeyword]);
const handleChanged = useCallback(async () => {
setUsers(null);
fetchUsers();
}, [fetchUsers]);
if (users === null) {
return <Loading />;
}
return <div className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto`}>
<table className={"table"}>
<thead>
<tr>
<td>{t("Username")}</td>
<td>{t("Created At")}</td>
<td>{t("Admin")}</td>
<td>{t("Can Upload")}</td>
<td>{t("Actions")}</td>
</tr>
</thead>
<tbody>
{
users.map((u) => {
return <UserRow key={u.id} user={u} onChanged={handleChanged} />
})
}
</tbody>
</table>
</div>
}
function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const buttonRef = createRef<HTMLButtonElement>();
const handleDelete = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.deleteUser(user.id);
if (res.success) {
showToast({
type: "success",
message: t("User deleted successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
const handleSetAdmin = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.setUserAdmin(user.id, true);
if (res.success) {
showToast({
type: "success",
message: t("User set as admin successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
const handleSetUser = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.setUserAdmin(user.id, false);
if (res.success) {
showToast({
type: "success",
message: t("User set as user successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
const handleSetUploadPermission = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.setUserUploadPermission(user.id, true);
if (res.success) {
showToast({
type: "success",
message: t("User set as upload permission successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
const handleRemoveUploadPermission = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.setUserUploadPermission(user.id, false);
if (res.success) {
showToast({
type: "success",
message: t("User removed upload permission successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
return <tr key={user.id} className={"hover"}>
<td>
{user.username}
</td>
<td>
{(new Date(user.created_at)).toLocaleDateString()}
</td>
<td>
{user.is_admin ? t("Yes") : t("No")}
</td>
<td>
{user.can_upload ? t("Yes") : t("No")}
</td>
<td>
<div className="dropdown dropdown-bottom dropdown-end">
<button ref={buttonRef} className="btn btn-square m-1" onClick={() => {
showPopup(<ul className="menu bg-base-100 rounded-box z-1 w-64 p-2 shadow-sm">
<h4 className="text-sm font-bold px-3 py-1 text-primary">{t("Actions")}</h4>
<PopupMenuItem onClick={() => {
const dialog = document.getElementById(`delete_user_dialog_${user.id}`) as HTMLDialogElement;
dialog.showModal();
}}>
<a>{t("Delete")}</a>
</PopupMenuItem>
{user.is_admin ? <PopupMenuItem onClick={handleSetUser}><a>{t("Set as user")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetAdmin}><a>{t("Set as admin")}</a></PopupMenuItem>}
{user.is_admin ? (
user.can_upload ? <PopupMenuItem onClick={handleRemoveUploadPermission}><a>{t("Remove upload permission")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetUploadPermission}><a>{t("Grant upload permission")}</a></PopupMenuItem>
) : null}
</ul>, buttonRef.current!);
}}>
{isLoading
? <span className="loading loading-spinner loading-sm"></span>
: <MdMoreHoriz size={20} className="opacity-50" />}
</button>
<dialog id={`delete_user_dialog_${user.id}`} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Delete User")}</h3>
<p className="py-4">{t("Are you sure you want to delete user")} <span className="font-bold">{user.username}</span>? {t("This action cannot be undone.")}</p>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
<button className="btn btn-error" onClick={handleDelete}>{t("Delete")}</button>
</form>
</div>
</div>
</dialog>
</div>
</td>
</tr>
}

View File

@@ -0,0 +1,361 @@
import {useRef, useState} from "react";
import {MdAdd, MdDelete, MdOutlineInfo} from "react-icons/md";
import {Tag} from "../network/models.ts";
import {network} from "../network/network.ts";
import {LuInfo} from "react-icons/lu";
import {useNavigate} from "react-router";
import showToast from "../components/toast.ts";
import {useTranslation} from "react-i18next";
import {app} from "../app.ts";
import {ErrorAlert} from "../components/alert.tsx";
export default function PublishPage() {
const [title, setTitle] = useState<string>("")
const [altTitles, setAltTitles] = useState<string[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [article, setArticle] = useState<string>("")
const [images, setImages] = useState<number[]>([])
const [isUploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setSubmitting] = useState(false)
const navigate = useNavigate()
const { t } = useTranslation();
const handleSubmit = async () => {
if (isSubmitting) {
return
}
if (!title) {
setError(t("Title cannot be empty"))
return
}
for (let i = 0; i < altTitles.length; i++) {
if (!altTitles[i]) {
setError(t("Alternative title cannot be empty"))
return
}
}
if (!tags || tags.length === 0) {
setError(t("At least one tag required"))
return
}
if (!article) {
setError(t("Description cannot be empty"))
return
}
const res = await network.createResource({
title: title,
alternative_titles: altTitles,
tags: tags.map((tag) => tag.id),
article: article,
images: images,
})
if (res.success) {
setSubmitting(false)
navigate("/resources/" + res.data!, {replace: true})
} else {
setSubmitting(false)
setError(res.message)
}
}
const addImage = () => {
if (isUploading) {
return
}
const input = document.createElement("input")
input.type = "file"
input.accept = "image/*"
input.onchange = async () => {
const files = input.files
if (!files || files.length === 0) {
return
}
const image = files[0]
setUploading(true)
const res = await network.uploadImage(image)
if (res.success) {
setUploading(false)
setImages([...images, res.data!])
} else {
setUploading(false)
showToast({message: t("Failed to upload image"), type: "error"})
}
}
input.click()
}
if (!app.user) {
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")}/>
}
if (!app.user?.is_admin) {
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")}/>
}
return <div className={"p-4"}>
<h1 className={"text-2xl font-bold my-4"}>{t("Publish Resource")}</h1>
<div role="alert" className="alert alert-info mb-2 alert-dash">
<MdOutlineInfo size={24}/>
<span>{t("All information, images, and files can be modified after publishing")}</span>
</div>
<p className={"my-1"}>{t("Title")}</p>
<input type="text" className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)}/>
<div className={"h-4"}></div>
<p className={"my-1"}>{t("Alternative Titles")}</p>
{
altTitles.map((title, index) => {
return <div key={index} className={"flex items-center my-2"}>
<input type="text" className="input w-full" value={title} onChange={(e) => {
const newAltTitles = [...altTitles]
newAltTitles[index] = e.target.value
setAltTitles(newAltTitles)
}}/>
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => {
const newAltTitles = [...altTitles]
newAltTitles.splice(index, 1)
setAltTitles(newAltTitles)
}}>
<MdDelete size={24}/>
</button>
</div>
})
}
<button className={"btn my-2"} type={"button"} onClick={() => {
setAltTitles([...altTitles, ""])
}}>
<MdAdd/>
{t("Add Alternative Title")}
</button>
<div className={"h-2"}></div>
<p className={"my-1"}>{t("Tags")}</p>
<p className={"my-1 pb-1"}>
{
tags.map((tag, index) => {
return <span key={index} className={"badge badge-primary mr-2"}>{tag.name}</span>
})
}
</p>
<TagInput onAdd={(tag) => {
setTags([...tags, tag])
}}/>
<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}/>
<span>{t("Images will not be displayed automatically, you need to reference them in the description")}</span>
</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>
<button className={"btn my-2"} type={"button"} onClick={addImage}>
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd/>}
{t("Upload Image")}
</button>
<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>
}
function TagInput({onAdd}: { onAdd: (tag: Tag) => void }) {
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 {t} = useTranslation();
const searchTags = async (keyword: string) => {
if (keyword.length === 0) {
return
}
setLoading(true)
setTags([])
setError(null)
const res = await network.searchTags(keyword)
if (!res.success) {
setError(res.message)
setLoading(false)
return
}
setTags(res.data!)
setLoading(false)
}
const handleChange = async (v: string) => {
setKeyword(v)
setTags([])
setError(null)
if (v.length !== 0) {
setLoading(true)
debounce.current.run(() => searchTags(v))
} else {
setLoading(false)
debounce.current.cancel()
}
}
const handleCreateTag = async (name: string) => {
setLoading(true)
const res = await network.createTag(name)
if (!res.success) {
setError(res.message)
setLoading(false)
return
}
onAdd(res.data!)
setKeyword("")
setTags([])
setLoading(false)
const input = document.getElementById("search_tags_input") as HTMLInputElement
input.blur()
}
let dropdownContent = <></>
if (error) {
dropdownContent = <div className="alert alert-error my-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{error}</span>
</div>
} else if (!keyword) {
dropdownContent = <div className="flex flex-row py-2 px-4">
<LuInfo size={20}/>
<span className={"w-2"}/>
<span className={"flex-1"}>{t("Please enter a search keyword")}</span>
</div>
} else if(isLoading) {
dropdownContent = <div className="flex flex-row py-2 px-4">
<span className={"loading loading-spinner loading-sm"}></span>
<span className={"w-2"}/>
<span className={"flex-1"}>{t("Searching...")}</span>
</div>
} else {
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined
dropdownContent = <>
{
tags.map((t) => {
return <li key={t.id} onClick={() => {
onAdd(t);
setKeyword("")
setTags([])
const input = document.getElementById("search_tags_input") as HTMLInputElement
input.blur()
}}><a>{t.name}</a></li>
})
}
{
!haveExactMatch && <li onClick={() => {
handleCreateTag(keyword)
}}><a>{t("Create Tag")}: {keyword}</a></li>
}
</>
}
return <div className={"dropdown dropdown-end"}>
<label className="input">
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input autoComplete={"off"} id={"search_tags_input"} tabIndex={0} type="text" className="grow" placeholder={t("Search Tags")} value={keyword} onChange={(e) => handleChange(e.target.value)}/>
</label>
<ul tabIndex={0} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow mt-2 border border-base-300">
{dropdownContent}
</ul>
</div>
}
class Debounce {
private timer: number | null = null
private readonly delay: number
constructor(delay: number) {
this.delay = delay
}
run(callback: () => void) {
if (this.timer) {
clearTimeout(this.timer)
}
this.timer = setTimeout(() => {
callback()
}, this.delay)
}
cancel() {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
}

View File

@@ -0,0 +1,78 @@
import {FormEvent, useState} from "react";
import {network} from "../network/network.ts";
import {app} from "../app.ts";
import {useNavigate} from "react-router";
import {useTranslation} from "react-i18next";
export default function RegisterPage() {
const {t} = useTranslation();
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const navigate = useNavigate();
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!username || !password) {
setError(t("Username and password cannot be empty"));
return;
}
if (password !== confirmPassword) {
setError(t("Passwords do not match"));
return;
}
setLoading(true);
const res = await network.register(username, password);
if (res.success) {
app.user = res.data!;
app.token = res.data!.token;
app.saveData();
navigate("/", {replace: true});
} else {
setError(res.message);
setLoading(false);
}
};
return <div className={"flex items-center justify-center w-full h-full bg-base-200"} id={"register-page"}>
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
<form onSubmit={onSubmit}>
<div className={"card-body"}>
<h1 className={"text-2xl font-bold"}>{t("Register")}</h1>
{error && <div role="alert" className="alert alert-error my-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{error}</span>
</div>}
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Username")}</legend>
<input type="text" className="input w-full" value={username} onChange={(e) => setUsername(e.target.value)}/>
</fieldset>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Password")}</legend>
<input type="password" className="input w-full" value={password} onChange={(e) => setPassword(e.target.value)}/>
</fieldset>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Confirm Password")}</legend>
<input type="password" className="input w-full" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}/>
</fieldset>
<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>
}

View File

@@ -0,0 +1,151 @@
import { useParams } from "react-router";
import {createContext, useCallback, useEffect, useState} from "react";
import {ResourceDetails, RFile} from "../network/models.ts";
import { network } from "../network/network.ts";
import showToast from "../components/toast.ts";
import Markdown from "react-markdown";
import "../markdown.css";
import Loading from "../components/loading.tsx";
import {MdAdd, MdOutlineArticle, MdOutlineComment, MdOutlineDataset} from "react-icons/md";
import {app} from "../app.ts";
export default function ResourcePage() {
const params = useParams()
const idStr = params.id
const id = idStr ? parseInt(idStr) : NaN
const [resource, setResource] = useState<ResourceDetails | null>(null)
const reload = useCallback(async () => {
if (!isNaN(id)) {
setResource(null)
const res = await network.getResourceDetails(id)
if (res.success) {
setResource(res.data!)
} else {
showToast({ message: res.message, type: "error" })
}
}
}, [id])
useEffect(() => {
if (!isNaN(id)) {
network.getResourceDetails(id).then((res) => {
if (res.success) {
setResource(res.data!)
} else {
showToast({ message: res.message, type: "error" })
}
})
}
}, [id])
if (isNaN(id)) {
return <div className="alert alert-error shadow-lg">
<div>
<span>Resource ID is required</span>
</div>
</div>
}
if (!resource) {
return <Loading />
}
return <context.Provider value={reload}>
<div className={"pt-2"}>
<h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1>
{
resource.alternativeTitles.map((e) => {
return <h2 className={"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"}>{e}</h2>
})
}
<button className="border-b-2 mx-4 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out">
<div className="flex items-center ">
<div className="avatar">
<div className="w-6 rounded-full">
<img src={network.getUserAvatar(resource.author)} alt={"avatar"} />
</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{resource.author.username}</div>
</div>
</button>
<p className={"px-4 pt-2"}>
{
resource.tags.map((e) => {
return <span className="badge badge-primary mr-2">{e.name}</span>
})
}
</p>
<div className="tabs tabs-box my-4 mx-2 p-4">
<label className="tab ">
<input type="radio" name="my_tabs" defaultChecked/>
<MdOutlineArticle className="text-xl mr-2"/>
<span className="text-sm">
Description
</span>
</label>
<div className="tab-content p-2">
<Article article={resource.article} />
</div>
<label className="tab">
<input type="radio" name="my_tabs"/>
<MdOutlineDataset className="text-xl mr-2"/>
<span className="text-sm">
Files
</span>
</label>
<div className="tab-content p-2">
<Files files={resource.files} />
</div>
<label className="tab">
<input type="radio" name="my_tabs"/>
<MdOutlineComment className="text-xl mr-2"/>
<span className="text-sm">
Comments
</span>
</label>
<div className="tab-content p-2">Comments</div>
</div>
<div className="h-4"></div>
</div>
</context.Provider>
}
const context = createContext<() => void>(() => {})
function Article({ article }: { article: string }) {
return <article>
<Markdown>{article}</Markdown>
</article>
}
function FileTile({ file }: { file: RFile }) {
// TODO: implement file tile
return <div></div>
}
function Files({files}: { files: RFile[]}) {
return <div>
{
files.map((file) => {
return <FileTile file={file} key={file.id}></FileTile>
})
}
{
app.isAdmin() && <div className={"flex flex-row-reverse"}>
<button className={"btn btn-accent shadow"}>
<MdAdd size={24}/>
<span className={"text-sm"}>
Upload
</span>
</button>
</div>
}
</div>
}

View File

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

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

8
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(),],
})