Compare commits

...

52 Commits

Author SHA1 Message Date
c9310250e4 add: markdown deletion 2025-06-23 15:41:03 +01:00
a08164e914 add: route to stand along page 2025-06-23 12:18:26 +01:00
30a46d5064 add: markdown template to json schema 2025-05-12 09:59:23 +01:00
e5affe3465 improve: update css 2025-05-11 20:14:29 +01:00
101666d26d upgrade react-query to v5 2025-05-09 00:44:53 +01:00
87b4246a9b add: backend api auth by apikey/apikey gen/apikey revoke 2025-05-06 18:54:10 +01:00
1ce2eebbfa improve: upgrade node to 20, upgrade react-query to v4 2025-04-27 00:36:42 +01:00
9ea44385ee add: markdown permission setting
improve: template
2025-04-25 00:39:01 +01:00
c20cb168ff fix: template defects 2025-04-18 02:16:17 +01:00
137ea649f8 fix: template defects 2025-04-17 21:44:45 +01:00
947b59e3ea add: template editor 2025-04-14 17:02:22 +01:00
09338a2683 improve: add setting button to pathnode 2025-03-20 18:37:13 +00:00
dc0ff3b406 improve: change db schema for settings 2025-03-20 13:58:24 +00:00
2c330904e4 add: webhook 2025-03-17 13:54:53 +00:00
8abf54eade fix: table boarder not shown in md 2025-03-05 18:29:13 +00:00
dd1ee9fd5c add: load backup 2025-03-05 17:33:17 +00:00
2911f8722e add: tree / search 2025-03-05 01:23:09 +00:00
39a69ca5b8 add: auto link feature 2025-01-17 16:33:39 +00:00
76b298ac8b add: markdown search feature 2025-01-17 09:20:20 +00:00
3f4669f776 improve: minor change in main/side nav css 2025-01-16 18:00:44 +00:00
cdf9039049 add: provide backup archive feature 2025-01-16 14:05:19 +00:00
74326f60c3 add: toggle button for path 2025-01-16 09:52:04 +00:00
cfcf823e4c improve: different color for path/md node 2025-01-09 14:19:31 +00:00
ed13196ef8 improve: support todo check list in md 2025-01-08 14:30:50 +00:00
2837edef31 improve: css for change order 2024-12-29 19:30:46 +00:00
75d083f11f add: order paths & mds 2024-12-29 18:52:39 +00:00
34ab63d0bf fix: latex in md creates scroll bar for root 2024-12-15 07:39:38 +00:00
a9d9b4e8f0 improve: fix README 2024-12-10 21:28:07 +00:00
76b64716c2 fix: home md not shown 2024-12-10 13:25:17 +00:00
d88fb34881 add: display home markdown 2024-12-09 21:15:04 +00:00
90897165db add: add logo 2024-12-09 09:47:26 +00:00
379843404f improve: adjust footer layout 2024-12-09 09:25:56 +00:00
ba2274c76e Merge remote-tracking branch 'origin/master'
# Conflicts:
#	tests/ConfigProvider.test.js
2024-12-09 08:53:31 +00:00
3f6461d17e add: tests 2024-12-09 08:53:03 +00:00
931ade90a3 add: tests 2024-12-09 08:46:20 +00:00
d8da574833 fix: public path 2024-12-09 08:00:54 +00:00
ba69541a7b improve: add production stage 2024-12-09 07:01:22 +00:00
0e6fd8409a improve: use react-query for caching 2024-12-08 17:11:14 +00:00
a31cec7ef0 fix: edit function of markdown 2024-12-07 12:03:23 +00:00
7eaf37223c fix: inconsistent style of path with/without index 2024-12-07 00:32:49 +00:00
20f205ba59 add: bind path to index markdown 2024-12-06 23:35:36 +00:00
a1473e51e7 improve: adjust layout of path node 2024-12-06 19:01:03 +00:00
df7ba4c490 fix: render of markdown preview 2024-12-06 17:22:42 +00:00
ccdded32a8 fix: path won't expend 2024-12-06 15:26:18 +00:00
ede31f85b5 Save Markdowns 2024-12-06 15:13:20 +00:00
6d96b658f0 kc token public key/token issue, path root set to 1 2024-12-06 10:04:03 +00:00
da1860a269 manage markdowns by path 2024-12-05 18:28:15 +00:00
788fd2f37a upgrade to bulma style 2024-12-05 13:57:42 +00:00
8bae53d026 read configs from env 2024-12-05 13:39:08 +00:00
3c53ef7a87 fix mem leak & ui / preview for editor 2024-12-05 08:58:31 +00:00
413896c54b markdown editor 2024-12-04 16:53:35 +00:00
55ddd17bf0 config for oauth 2024-12-04 14:06:30 +00:00
90 changed files with 224600 additions and 432 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
summerizer.py
node_modules

32
BuildConfig.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/bin/sh
BACKEND_HOST="${BACKEND_HOST:-http://localhost:5000}"
FRONTEND_HOST="${FRONTEND_HOST:-http://localhost:80}"
KC_CLIENT_ID="${KC_CLIENT_ID:-labdev}"
KC_HOST="${KC_HOST:-https://login.hangman-lab.top}"
KC_REALM="${KC_REALM:-Hangman-Lab}"
DEBUG="${DEBUG:false}"
rm -f /usr/share/nginx/html/config.js
cat <<EOL > /usr/share/nginx/html/config.json
{
"BACKEND_HOST": "${BACKEND_HOST}",
"FRONTEND_HOST": "${FRONTEND_HOST}",
"KC_CLIENT_ID": "${KC_CLIENT_ID}",
"OIDC_CONFIG": {
"authority": "${KC_HOST}/realms/${KC_REALM}",
"client_id": "${KC_CLIENT_ID}",
"redirect_uri": "${FRONTEND_HOST}/callback",
"post_logout_redirect_uri": "${FRONTEND_HOST}",
"response_type": "code",
"scope": "openid profile email roles",
"popup_redirect_uri": "${FRONTEND_HOST}/popup_callback",
"silent_redirect_uri": "${FRONTEND_HOST}/silent_callback"
},
"DEBUG": ${DEBUG}
}
EOL
exec "$@"

View File

@@ -1,6 +1,7 @@
FROM node:18 as build-stage
FROM node:20-alpine AS build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
@@ -8,6 +9,19 @@ RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
FROM nginx:stable-alpine AS production-stage
RUN apk add --no-cache bash
WORKDIR /app
COPY package*.json ./
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY --from=build-stage /app/public/icons /usr/share/nginx/html/icons
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY BuildConfig.sh /docker-entrypoint.d/10-build-config.sh
RUN chmod a+x /docker-entrypoint.d/10-build-config.sh
EXPOSE $FRONTEND_PORT
CMD ["nginx", "-g", "daemon off;"]

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
# Hangman Lab Frontend
This project serves as a lightweight frontend solution for personal websites, designed to work seamlessly with Keycloak for authentication and authorization. It provides an easy-to-use interface for managing and publishing markdown files, allowing users to maintain their personal content effortlessly.
## Table of Contents
- [Features](#features)
- [Technologies Used](#technologies-used)
- [Installation](#installation)
- [Configuration](#configuration)
- [Development](#development)
- [Building for Production](#building-for-production)
- [Docker Deployment](#docker-deployment)
- [License](#license)
## Features
- **User Authentication**: Secure login and logout functionalities using **Keycloak** and **OpenID Connect (OIDC)**.
- **Markdown Management**: Create, edit, and view markdown files with live preview and syntax highlighting for a streamlined content creation experience.
- **Docker Support**: Containerized setup for easy deployment and scalability.
## Technologies Used
This project uses the following open-source libraries, all of which are licensed under the MIT License:
- [Bulma](https://bulma.io/) (MIT)
- [Katex](https://katex.org/) (MIT)
- [oidc-client-ts](https://github.com/authts/oidc-client-ts) (Apache-2.0)
- [React](https://reactjs.org/) (MIT)
- [React-DOM](https://reactjs.org/) (MIT)
- [React-Markdown](https://github.com/remarkjs/react-markdown) (MIT)
- [React-Query](https://react-query-v3.tanstack.com/) (MIT)
- [React-Router-DOM](https://reactrouter.com/) (MIT)
- [React-Syntax-Highlighter](https://github.com/react-syntax-highlighter/react-syntax-highlighter) (MIT)
- [Rehype-Katex](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex) (MIT)
- [Rehype-Raw](https://github.com/rehypejs/rehype-raw) (MIT)
- [Remark-Math](https://github.com/remarkjs/remark-math) (MIT)
The development dependencies (e.g., Babel, Webpack, and loaders) are also licensed under the MIT License.
## Installation
### Prerequisites
- **Node.js** (v14.x or higher)
- **npm** (v6.x or higher) or **Yarn**
- **Docker** (optional, for containerized deployment)
- **Keycloak Server**: Ensure you have access to a Keycloak server for authentication.
### Steps
1. **Clone the Repository**
```bash
git clone https://git.hangman-lab.top/hzhang/HangmanLab.Frontend.git
cd HangmanLab.Frontend
```
2. **Install Dependencies**
Using npm:
```bash
npm install
```
Or using Yarn:
```bash
yarn install
```
3. **Configure environment variables**
See Configuration[#configuration]
4. **BuildConfig.sh**
```bash
chmod +x BuildConfig
./BuildConfig.sh
```
## Configuration
Set the following environment variables before running BuildConfig.sh
### Environment Variables
- `BACKEND_HOST`: URL of the backend server (default: `http://localhost:5000`)
- `FRONTEND_HOST`: URL of the frontend server (default: `http://localhost:3000`)
- `KC_CLIENT_ID`: Keycloak client ID
- `KC_HOST`: Keycloak server URL
- `KC_REALM`: Keycloak realm
## Development
### Running the Development Server
Start the development server with hot reloading:
```bash
npm start
```
Or using Yarn:
```bash
yarn start
```
The application will be accessible at `http://localhost:3000` by default.
## Docker Deployment
### Image
Pull the latest Docker image from the registry:
```bash
docker pull git.hangman-lab.top/hzhang/hangmanlab-frontend:latest
```
### Running the Docker Container
Ensure your backend service (e.g., running at http://localhost:5000) is up and accessible. Then, run the Docker container:
```bash
docker run -d -p 80:80 --name hangmanlab-frontend hangmanlab-frontend:latest
```
#### Note:
- The container listens on port 80. Adjust the port mapping (-p) as needed.
- Make sure to set the appropriate environment variables either in the Dockerfile or via Docker environment variable options.
## License
[MIT][license] © [hzhang][author]
<!-- Definitions -->
[author]: https://hangman-lab.top
[license]: license

15
jest.config.js Normal file
View File

@@ -0,0 +1,15 @@
module.exports = {
testEnvironment: "jsdom",
setupFilesAfterEnv: ["@testing-library/jest-dom", "<rootDir>/jest.setup.js"],
moduleNameMapper: {
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
},
transform: {
"^.+\\.[t|j]sx?$": "babel-jest",
},
transformIgnorePatterns: [
'node_modules/(?!(@thi.ng)/)',
"node_modules/(?!node-fetch|katex|react-markdown|remark-math|rehype-katex|rehype-raw)"
],
};

6
jest.setup.js Normal file
View File

@@ -0,0 +1,6 @@
const { TextEncoder, TextDecoder } = require("util");
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
const fetch = (...args) =>
import("node-fetch").then(({ default: fetch }) => fetch(...args));
global.fetch = fetch;

22
licence Normal file
View File

@@ -0,0 +1,22 @@
(The MIT License)
Copyright (c) hzhang <hzhang@hangman-lab.top>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

13
nginx.conf Normal file
View File

@@ -0,0 +1,13 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /icons/ {
try_files $uri =404;
}
error_page 404 /index.html;
}

6453
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,18 +12,47 @@
"author": "",
"license": "ISC",
"dependencies": {
"@reduxjs/toolkit": "^2.7.0",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"assert": "^2.1.0",
"axios": "^1.7.9",
"bulma": "^1.0.2",
"katex": "^0.16.11",
"oidc-client-ts": "^3.1.0",
"path-browserify": "^1.0.1",
"prismjs": "^1.30.0",
"process": "^0.11.10",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.1"
"react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.0.1",
"react-syntax-highlighter": "^15.6.1",
"redux": "^5.0.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"util": "^0.12.5"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/preset-react": "^7.25.9",
"@babel/preset-react": "^7.26.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"axios-mock-adapter": "^2.1.0",
"babel-jest": "^29.7.0",
"babel-loader": "^9.2.1",
"css-loader": "^7.1.2",
"dotenv-webpack": "^8.1.0",
"html-webpack-plugin": "^5.6.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"node-fetch": "^3.3.2",
"sass": "^1.81.0",
"sass-loader": "^16.0.3",
"style-loader": "^4.0.0",

BIN
public/icons/email.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
public/icons/git.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
public/icons/linkedin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
public/icons/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

1027
public/icons/logo32.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 58 KiB

211484
public/icons/logoxl-var1.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 13 MiB

View File

@@ -4,8 +4,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hangman Lab</title>
<link rel="icon" type="image/png" href="./icons/logo.png">
</head>
<body>
<div id="root"></div>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css"
rel="stylesheet"
/>
</body>
</html>

View File

@@ -7,10 +7,15 @@
.content-container {
display: flex;
flex: 1;
overflow: hidden;
}
.main-content {
flex: 1;
padding: 1rem;
padding: 1rem 1rem 100px 1rem;
overflow-y: auto;
}
:root {
overflow: hidden;
}

View File

@@ -1,30 +1,57 @@
import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import MainNavigation from "./components/MainNavigation";
import SideNavigation from "./components/SideNavigation";
import MarkdownContent from "./components/MarkdownContent";
import {BrowserRouter as Router, Navigate, Route, Routes} from "react-router-dom";
import { Provider } from 'react-redux';
import { store } from './store';
import MainNavigation from "./components/Navigations/MainNavigation";
import SideNavigation from "./components/Navigations/SideNavigation";
import MarkdownContent from "./components/Markdowns/MarkdownContent";
import MarkdownEditor from "./components/Markdowns/MarkdownEditor";
import StandaloneMarkdownPage from "./components/Markdowns/StandaloneMarkdownPage";
import "./App.css";
import Callback from "./Callback";
import Callback from "./components/KeycloakCallbacks/Callback";
import Footer from "./components/Footer";
import PopupCallback from "./components/KeycloakCallbacks/PopupCallback";
import SilentCallback from "./components/KeycloakCallbacks/SilentCallback";
import MarkdownTemplateEditor from "./components/MarkdownTemplate/MarkdownTemplateEditor";
const App = () => {
return (
<Router>
<div className="app-container">
<MainNavigation />
<div className="content-container">
<SideNavigation />
<main className="main-content">
<Routes>
<Route path="/" element={<h1>Welcome to My React Project</h1>} />
<Route path="/markdown/:id" element={<MarkdownContent />} />
<Route path="/callback" element={<Callback />} />
<Route path="/test" element={<h1>TEST</h1>}></Route>
</Routes>
</main>
</div>
</div>
</Router>
<Provider store={store}>
<Router>
<Routes>
<Route path="/pg/*" element={<StandaloneMarkdownPage />} />
<Route path="*" element={
<div className="app-container">
<MainNavigation />
<div className="content-container">
<SideNavigation />
<main className="main-content">
<Routes>
<Route
path="/"
element={<Navigate to = "/markdown/1"/>}
/>
<Route path="/testx" element={<h2>test2</h2>}/>
<Route path="/markdown/:strId" element={<MarkdownContent />} />
<Route path="/callback" element={<Callback />} />
<Route path="/test" element={<h1>TEST</h1>}></Route>
<Route path="/markdown/create" element={<MarkdownEditor />} />
<Route path="/markdown/edit/:strId" element={<MarkdownEditor />} />
<Route path="/popup_callback" element={<PopupCallback />} />
<Route path="/silent_callback" element={<SilentCallback />} />
<Route path="/template/create" element={<MarkdownTemplateEditor />} />
<Route path="/template/edit/:strId" element={<MarkdownTemplateEditor />} />
</Routes>
</main>
</div>
</div>
} />
</Routes>
<Footer />
</Router>
</Provider>
);
};
export default App;
export default App;

View File

@@ -1,37 +1,100 @@
import React, { createContext, useEffect, useState } from "react";
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import { UserManager } from "oidc-client-ts";
import {oidcConfig} from "./oicdConfig";
import { ConfigContext } from "./ConfigProvider";
export const AuthContext = createContext({
user: null,
login: () => {},
logout: () => {},
roles: [],
});
const AuthProvider = ({ children }) => {
const { config, isLoading, error } = useContext(ConfigContext);
const [user, setUser] = useState(null);
const userManager = new UserManager(oidcConfig);
const [roles, setRoles] = useState([]);
const userManager = useMemo(() => {
if (config && config.OIDC_CONFIG) {
return new UserManager(config.OIDC_CONFIG);
}
return null;
}, [config]);
useEffect(() => {
userManager.getUser().then((user) => {
if (user && !user.expired) {
setUser(user);
}
});
}, [userManager]);
if (isLoading || error || !userManager) return;
userManager.getUser()
.then((user) => {
if (user && !user.expired) {
setUser(user);
localStorage.setItem("accessToken", user.access_token);
const clientRoles = user?.profile?.resource_access?.[config.KC_CLIENT_ID]?.roles || [];
setRoles(clientRoles);
} else if (user && user.expired) {
userManager
.signinSilent()
.then((newUser) => {
setUser(newUser);
localStorage.setItem("accessToken", newUser.access_token);
const clientRoles = newUser?.profile?.resource_access?.[config.KC_CLIENT_ID]?.roles || [];
setRoles(clientRoles);
})
.catch((err) => {
console.error(err);
logout();
});
}
})
.catch((err) => {
console.error(err);
logout();
});
const onUserLoaded = (loadedUser) => {
setUser(loadedUser);
localStorage.setItem("accessToken", loadedUser.access_token);
const clientRoles = loadedUser?.profile?.resource_access?.[config.KC_CLIENT_ID]?.roles || [];
setRoles(clientRoles);
};
const onUserUnloaded = () => {
setUser(null);
setRoles([]);
localStorage.removeItem("accessToken");
};
userManager.events.addUserLoaded(onUserLoaded);
userManager.events.addUserUnloaded(onUserUnloaded);
return () => {
userManager.events.removeUserLoaded(onUserLoaded);
userManager.events.removeUserUnloaded(onUserUnloaded);
};
}, [userManager, isLoading, error, config]);
const login = () => {
console.log('triggered');
userManager.signinRedirect().catch((err) => {console.log(err)});
}
const logout = () => userManager.signoutRedirect();
if (userManager) {
userManager
.signinRedirect()
.catch((err) => {
console.log(config);
console.log(err);
});
}
};
const logout = () => {
if (userManager) {
userManager.signoutRedirect();
}
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
<AuthContext.Provider value={{ user, roles, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;
export default AuthProvider;

View File

@@ -1,17 +0,0 @@
import React, { useEffect } from "react";
import { UserManager } from "oidc-client-ts";
import {oidcConfig} from "./oicdConfig";
const Callback = () => {
useEffect(() => {
const userManager = new UserManager(oidcConfig);
userManager.signinRedirectCallback().then(() => {
window.location.href = "/";
});
}, []);
return <div>Logging in...</div>;
};
export default Callback;

61
src/ConfigProvider.js Normal file
View File

@@ -0,0 +1,61 @@
import React, { createContext, useContext, useEffect, useState } from "react";
export const ConfigContext = createContext({
config: {
BACKEND_HOST: null,
FRONTEND_HOST: null,
KC_CLIENT_ID: null,
OIDC_CONFIG: {},
},
isLoading: true,
error: null,
});
export const useConfig = () => useContext(ConfigContext).config;
const ConfigProvider = ({ children }) => {
const [config, setConfig] = useState({
BACKEND_HOST: null,
FRONTEND_HOST: null,
KC_CLIENT_ID: null,
OIDC_CONFIG: {},
});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/config.json')
.then((res) => {
if (!res.ok) {
throw new Error(`Failed to fetch config: ${res.statusText}`);
}
return res.json();
})
.then((data) => {
setConfig(data);
setIsLoading(false);
})
.catch((err) => {
console.error("Error fetching config:", err);
setError(err);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <div>Loading configuration...</div>;
}
if (error) {
return <div>Error loading configuration: {error.message}</div>;
}
return (
<ConfigContext.Provider value={{ config, isLoading, error }}>
{children}
</ConfigContext.Provider>
);
}
export default ConfigProvider;

View File

@@ -0,0 +1,14 @@
import React from "react";
import {useConfig} from "../../ConfigProvider";
import {ReactQueryDevtools} from "@tanstack/react-query-devtools";
export const ControlledReactQueryDevtools = () => {
const config = useConfig();
if(config.DEBUG)
return (<ReactQueryDevtools />);
return (<></>);
};
export default ControlledReactQueryDevtools;

105
src/components/Footer.css Normal file
View File

@@ -0,0 +1,105 @@
.footer {
background-color: #333;
color: #fff;
padding: 1rem;
position: fixed;
width: 100%;
display: flex;
bottom: 0;
flex-direction: column;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
overflow: visible;
max-height: 5rem;
transition: max-height 0.3s ease;
}
.footer.expanded {
max-height: 20rem;
}
.footer-details{
opacity: 0;
max-height: 0;
overflow: hidden;
transition: opacity 0.3s ease, max-height 0.3s ease;
}
.footer-details.expanded {
opacity: 1;
max-height: 15rem;
}
.footer-content {
margin-bottom: auto;
text-align: center;
width: 100%;
}
.footer-content a {
color: #fff;
font-size: 1.2rem;
text-decoration: underline;
}
.footer-icons {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: flex-start;
margin-left: 1rem;
width: 100%;
}
.footer-icon {
width: 40px;
height: 40px;
transition: transform 0.2s;
}
.footer-icon:hover {
transform: scale(1.2);
}
.footer-icons a {
display: flex;
align-items: center;
gap: 0.5rem;
color: #00d1b2;
text-decoration: none;
}
.footer-icons a:hover {
color: #00a6a2;
}
.toggle-button {
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: #007bff;
color: white;
border: none;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: transform 0.2s ease, background-color 0.3s ease;
position: absolute;
top: -1rem;
left: 50%;
transform: translateX(-50%);
}
.toggle-button:hover {
background-color: #0056b3;
transform: scale(1.1) translateX(-50%);
}
.toggle-button:focus {
outline: none;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);
}

85
src/components/Footer.js Normal file
View File

@@ -0,0 +1,85 @@
import React from "react";
import "./Footer.css";
const Footer = () => {
const [isExpanded, setIsExpanded] = React.useState(false);
const [isVisible, setIsVisible] = React.useState(false);
const toggleExpand = () => {
if(!isExpanded) {
setIsVisible(true);
}
setIsExpanded((prev) => !prev);
};
const onTransitionEnd = () => {
if(!isExpanded) {
setIsVisible(false);
}
};
return (
<footer className={`footer ${isExpanded ? "expanded" : ""}`}>
<button
className="toggle-button"
onClick={toggleExpand}
type="button"
>
{isExpanded ? "↓" : "↑"}
</button>
<div className={`footer-content`}>
<p>&copy; {new Date().getFullYear()} Hangman Lab. {!isVisible && (<span>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="mailto:hzhang@hangman-lab.top">email</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://git.hangman-lab.top/hzhang/HangmanLab">git</a>
&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;
</span>
)}</p>
{
isVisible && (
<div
className={`footer-details ${isExpanded ? "expanded" : ""}`}
onTransitionEnd={onTransitionEnd}
>
<div className="footer-icons">
<a
href="https://www.linkedin.com/in/zhhrozhh/"
target="_blank"
rel="noopener noreferrer"
>
<img
src="/icons/linkedin.png"
alt="LinkedIn"
className="footer-icon"
/>
LinkedIn
</a>
<a
href="https://git.hangman-lab.top/hzhang/HangmanLab"
target="_blank"
rel="noopener noreferrer"
>
<img
src="/icons/git.png"
alt="Git"
className="footer-icon"
/>
Git
</a>
<a href="mailto:hzhang@hangman-lab.top">
<img
src="/icons/email.png"
alt="Email"
className="footer-icon"
/>
Email
</a>
</div>
</div>
)
}
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,19 @@
import React, {useContext, useEffect} from "react";
import { UserManager } from "oidc-client-ts";
import {ConfigContext} from "../../ConfigProvider";
const Callback = () => {
const config = useContext(ConfigContext).config;
useEffect(() => {
const userManager = new UserManager(config.OIDC_CONFIG);
userManager.signinRedirectCallback()
.then(() => {
window.location.href = "/";
});
}, []);
return <div>Logging in...</div>;
};
export default Callback;

View File

@@ -0,0 +1,24 @@
import React, { useEffect, useContext } from "react";
import { UserManager } from "oidc-client-ts";
import {ConfigContext} from "../../ConfigProvider";
const PopupCallback = () => {
const { config } = useContext(ConfigContext);
useEffect(() => {
const userManager = new UserManager(config.OIDC_CONFIG);
userManager.signinPopupCallback()
.then(() => {
window.close();
})
.catch((err) => {
console.error("Popup callback error:", err);
window.close();
});
}, [config]);
return <div>Processing...</div>;
};
export default PopupCallback;

View File

@@ -0,0 +1,21 @@
import React, { useEffect, useContext } from "react";
import { UserManager } from "oidc-client-ts";
import { ConfigContext } from "../../ConfigProvider";
const SilentCallback = () => {
const { config } = useContext(ConfigContext);
useEffect(() => {
const userManager = new UserManager(config.OIDC_CONFIG);
userManager.signinSilentCallback()
.then(() => {
})
.catch((err) => {
console.error("Silent callback error:", err);
});
}, [config]);
return <div>Renew...</div>;
};
export default SilentCallback;

View File

@@ -1,26 +0,0 @@
/*src/components/MainNavigation.css*/
.main-navigation {
background-color: #333;
color: white;
padding: 1rem;
}
.main-navigation ul {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.main-navigation ul li {
margin-right: 1rem;
}
.main-navigation ul li a {
color: white;
text-decoration: none;
}
.main-navigation ul li a:hover {
text-decoration: underline;
}

View File

@@ -1,44 +0,0 @@
//src/components/MainNavigation.js
import React, {useContext} from "react";
import { Link } from "react-router-dom";
import "./MainNavigation.css";
import {AuthContext} from "../AuthProvider";
const MainNavigation = () => {
const { user, login, logout } = useContext(AuthContext);
return (
<nav className="main-navigation">
<ul>
<li>
<Link to="/">Home</Link>
</li>
{ user && user.profile ? (
<div>
<h1>{user.profile.name}</h1>
<Link to="/logout">Logout</Link>
</div>
) : (
<li>
<button onClick={login}>Login</button>
</li>
)
}
<li>
<a href="https://mail.hangman-lab.top" target="_blank" rel="noopener noreferrer">
MailBox
</a>
</li>
<li>
<a href="https://git.hangman-lab.top" target="_blank" rel="noopener noreferrer">
Git
</a>
</li>
</ul>
</nav>
);
};
export default MainNavigation;

View File

@@ -1,34 +0,0 @@
//src/components/MarkdownContent.js
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import {fetchWithCache} from "../utils/fetchWithCache";
const MarkdownContent = () => {
const { id } = useParams();
const [content, setContent] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
fetchWithCache(`/api/markdown/${id}`)
.then((data) => setContent(data))
.catch((error) => setError(error));
}, [id]);
if (error) {
return <div>Error: {error}</div>;
}
if (!content) {
return <div>Loading...</div>;
}
return (
<div className="markdown-content">
<pre>{content}</pre>
</div>
);
};
export default MarkdownContent;

View File

@@ -0,0 +1,65 @@
import React, { useState } from "react";
const EnumsEditor = ({ enums, onChange }) => {
const [_enums, setEnums] = useState(enums || []);
return (
<div className="box">
<ul>
{_enums.map((item, index) => (
<li key={index} className="field has-addons" style={{ marginBottom: "0.5rem" }}>
<div className="control is-expanded">
<input
className="input is-small"
type="text"
value={item}
onChange={(e) => {
const updated = [..._enums];
updated[index] = e.target.value;
setEnums(updated);
onChange(updated);
}}
/>
</div>
<div className="control">
<button
className="button is-small is-danger"
type="button"
onClick={() => {
const updated = [..._enums];
updated.splice(index, 1);
setEnums(updated);
onChange(updated);
}}
>
<span className="icon is-small">
<i className="fas fa-times" />
</span>
</button>
</div>
</li>
))}
</ul>
<div className="field">
<div className="control">
<button
className="button is-small is-primary"
type="button"
onClick={() => {
const updated = [..._enums, ""];
setEnums(updated);
onChange(updated);
}}
>
<span className="icon is-small">
<i className="fas fa-plus" />
</span>
<span>Add Enum</span>
</button>
</div>
</div>
</div>
);
};
export default EnumsEditor;

View File

@@ -0,0 +1,18 @@
import React, {useEffect, useState} from 'react';
const LayoutEditor = ({layout, onChange}) => {
const [_layout, setLayout] = useState(layout || "");
useEffect(() => {setLayout(layout)}, [layout]);
return (
<textarea
className="textarea"
style={{ height: "60vh" }}
value={_layout}
onChange={(e) => {
setLayout(e.target.value);
onChange(e.target.value);
}}
/>
);
};
export default LayoutEditor;

View File

@@ -0,0 +1,99 @@
import React, {useContext, useEffect, useState} from "react";
import { AuthContext } from "../../AuthProvider";
import { useNavigate, useParams } from "react-router-dom";
import { useMarkdownTemplate, useSaveMarkdownTemplate } from "../../utils/queries/markdown-template-queries";
import LayoutEditor from "./LayoutEditor";
import ParametersManager from "./ParametersManager";
import "bulma/css/bulma.min.css";
const MarkdownTemplateEditor = () => {
const navigate = useNavigate();
const { strId } = useParams();
const id = Number(strId);
const { data: template, isFetching: templateIsFetching } = useMarkdownTemplate(id);
const saveMarkdownTemplate = useSaveMarkdownTemplate();
const [title, setTitle] = useState(template?.id || "");
const [parameters, setParameters] = useState(template?.parameters || []);
const [layout, setLayout] = useState(template?.layout || "");
const { roles } = useContext(AuthContext);
useEffect(() => {
setTitle(template?.title || "");
setParameters(template?.parameters || []);
setLayout(template?.layout || "");
}, [template]);
if (templateIsFetching) {
return <p>Loading...</p>;
}
if (!roles.includes("admin") || roles.includes("creator"))
return <div className="notification is-danger">Permission Denied</div>;
const handleSave = () => {
saveMarkdownTemplate.mutate(
{ id, data: { title, parameters, layout } },
{
onSuccess: () => {
navigate("/");
},
onError: () => {
alert("Error saving template.");
}
}
);
};
return (
<section className="section">
<div className="container">
<h2 className="title is-4">Markdown Template Editor</h2>
<div className="field">
<label className="label">Title:</label>
<div className="control">
<input
className="input"
type="text"
placeholder="Enter template title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</div>
<div className="columns is-variable is-8">
<div className="column">
<h3 className="title is-5">Layout</h3>
<div className="box">
<LayoutEditor
layout={layout}
parameters={parameters}
onChange={(newLayout) => setLayout(newLayout)}
/>
</div>
</div>
<div className="column">
<h3 className="title is-5">Parameters</h3>
<ParametersManager
parameters={parameters}
onChange={(newParameters) => setParameters(newParameters)}
/>
</div>
</div>
<div className="field is-grouped">
<div className="control">
<button className="button is-primary" onClick={handleSave}>
Save Template
</button>
</div>
</div>
</div>
</section>
);
};
export default MarkdownTemplateEditor;

View File

@@ -0,0 +1,121 @@
import React, {useEffect, useState} from "react";
import TypeEditor from "./TypeEditor";
const ParametersManager = ({ parameters, onChange }) => {
const [_parameters, setParameters] = useState(parameters || []);
const [expandedStates, setExpandedStates] = useState({});
const handleAdd = () => {
const updated = [
..._parameters,
{
name: "",
type: {
base_type: "string",
definition: {}
}
}
];
setParameters(updated);
onChange(updated);
};
const handleTypeChange = (index, newType) => {
const updated = [..._parameters];
if (newType.base_type === "list" && !newType.extend_type) {
newType.extend_type = {
base_type: "string",
definition: {}
};
}
updated[index].type = newType;
setParameters(updated);
onChange(updated);
};
useEffect(() => {
setParameters(parameters);
}, [parameters]);
const handleNameChange = (index, newName) => {
const updated = [..._parameters];
updated[index].name = newName;
setParameters(updated);
onChange(updated);
};
const handleDelete = (index) => {
const updated = [..._parameters];
updated.splice(index, 1);
setParameters(updated);
onChange(updated);
};
const toggleExpand = (index) => {
setExpandedStates(prev => ({
...prev,
[index]: !prev[index]
}));
};
return (
<div className="box">
<div className="field">
<div className="control">
<button className="button is-primary" onClick={handleAdd}>
Add Parameter
</button>
</div>
</div>
<div style={{ maxHeight: "50vh", overflowY: "auto" }}>
{_parameters.map((param, index) => (
<div key={index} className="box" style={{ marginBottom: "0.5rem" }}>
<div className="field is-grouped is-align-items-end">
<div className="control">
<label className="label">Name:</label>
</div>
<div className="control is-expanded">
<input
type="text"
className="input"
value={param.name}
onChange={(e) => handleNameChange(index, e.target.value)}
placeholder="Parameter name"
/>
</div>
<div className="control">
<button
className="button is-danger"
onClick={() => handleDelete(index)}
>
Delete
</button>
</div>
</div>
<div className="field">
<div className="is-flex is-justify-content-space-between is-align-items-center mb-1">
<label className="label mb-0">Type:</label>
<button
className="button is-small"
onClick={() => toggleExpand(index)}
>
{expandedStates[index] ? "-" : "+"}
</button>
</div>
{expandedStates[index] && (
<div className="control">
<TypeEditor
type={param.type}
onChange={(newType) => handleTypeChange(index, newType)}
/>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
};
export default ParametersManager;

View File

@@ -0,0 +1,61 @@
import React, { useEffect, useState } from "react";
import { useMarkdownTemplates } from "../../utils/queries/markdown-template-queries";
const TemplateSelector = ({ template, onChange, onCreate }) => {
const { data: templates, isFetching: templatesAreFetching } = useMarkdownTemplates();
const [_template, setTemplate] = useState(
templates?.find((t) => t.id === template?.id) || {
title: "",
parameters: [],
layout: "",
}
);
useEffect(() => {
setTemplate(
templates?.find((t) => t.id === template?.id) || {
title: "",
parameters: [],
layout: "",
}
);
}, [template, templates]);
if (templatesAreFetching) {
return <p>Loading...</p>;
}
return (
<div className="field">
<label className="label">Select Template</label>
<div className="control">
<div className="select is-fullwidth is-primary">
<select
value={template?.id || ""}
onChange={(e) => {
const id = parseInt(e.target.value, 10);
const selectedTemplate = templates.find((t) => t.id === id) || {
title: "",
parameters: [],
layout: "",
};
onChange(selectedTemplate);
if (onCreate) {
onCreate(selectedTemplate);
}
}}
>
<option value="">(None)</option>
{templates.map((tmpl) => (
<option key={tmpl.id} value={tmpl.id}>
{tmpl.title}
</option>
))}
</select>
</div>
</div>
</div>
);
};
export default TemplateSelector;

View File

@@ -0,0 +1,121 @@
import React from 'react';
import EnumsEditor from './EnumsEditor';
import TemplateSelector from './TemplateSelector';
const TypeEditor = ({ type, onChange }) => {
const [_type, setType] = React.useState(type || {});
const updateType = (updated) => {
setType(updated);
onChange(updated);
};
const renderExtraFields = () => {
switch (_type.base_type) {
case 'enum':
return (
<div className="field">
<label className="label">Enums</label>
<div className="control">
<EnumsEditor
enums={_type.definition.enums}
onChange={(newEnums) => {
updateType({
..._type,
definition: { ..._type.definition, enums: newEnums },
});
}}
/>
</div>
</div>
);
case 'list':
return (
<div className="box">
<div className="field">
<label className="label">Extend Type</label>
<div className="control">
<TypeEditor
type={_type.extend_type}
onChange={(extendType) => {
updateType({ ..._type, extend_type: extendType });
}}
/>
</div>
</div>
<div className="field">
<label className="label">Iter Layout</label>
<div className="control">
<textarea
className="textarea"
value={_type.definition.iter_layout || ''}
onChange={(e) => {
updateType({
..._type,
definition: {
..._type.definition,
iter_layout: e.target.value,
},
});
}}
/>
</div>
</div>
</div>
);
case 'template':
return (
<div className="field">
<div className="control">
<TemplateSelector
template={_type.definition.template}
onChange={(newTemplate) => {
updateType({
..._type,
definition: {
..._type.definition,
template: newTemplate,
},
});
}}
/>
</div>
</div>
);
default:
return null;
}
};
return (
<div className="box">
<div className="field">
{/*<label className="label">Type</label>*/}
<div className="control">
<div className="select is-fullwidth">
<select
value={_type.base_type || ''}
onChange={(e) => {
const updated = { base_type: e.target.value, definition: {} };
updateType(updated);
}}
>
<option value="string">string</option>
<option value="markdown">markdown</option>
<option value="enum">enum</option>
<option value="list">list</option>
<option value="template">template</option>
</select>
</div>
</div>
</div>
{renderExtraFields()}
</div>
);
};
export default TypeEditor;

View File

@@ -0,0 +1,42 @@
.markdown-content-container {
margin: 20px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.markdown-content {
font-family: "Arial", sans-serif;
line-height: 1.6;
}
.markdown-content pre {
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
.markdown-content code {
background: #f9f9f9;
padding: 2px 4px;
border-radius: 4px;
font-size: 0.95em;
}
.markdown-content-container-header {
align-items: center;
display: flex;
justify-content: space-between;
}
.markdown-content-container-header h1 {
margin: 0;
font-size: 1.5rem;
line-height: 1.5;
}
.markdown-content-container-header .button {
margin-left: auto;
height: auto;
display: inline-flex;
align-items: center;
}

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useState } from "react";
import {Link, useParams} from "react-router-dom";
import "katex/dist/katex.min.css";
import "./MarkdownContent.css";
import MarkdownView from "./MarkdownView";
import PermissionGuard from "../PermissionGuard";
import {useMarkdown} from "../../utils/queries/markdown-queries";
import {usePath} from "../../utils/queries/path-queries";
import {useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
const MarkdownContent = () => {
const { strId } = useParams();
const id = Number(strId);
const [indexTitle, setIndexTitle] = useState(null);
const [isSettingModalOpen, setSettingModalOpen] = useState(false);
const {data: markdown, isLoading, error} = useMarkdown(id);
const {data: path, isFetching: isPathFetching} = usePath(markdown?.path_id);
const {data: setting, isFetching: isSettingFetching} = useMarkdownSetting(markdown?.setting_id);
const {data: templateSetting, isFetching: isTemplateSettingFetching} = useMarkdownTemplateSetting(setting?.template_setting_id);
const {data: template, isFetching: isTemplateFetching} = useMarkdownTemplate(templateSetting?.template_id);
useEffect(() => {
if(markdown && markdown.title === "index" && path){
setIndexTitle(path.id === 1 ? "Home" : path.name);
}
}, [markdown, path]);
const notReady = isLoading || isPathFetching || isSettingFetching || isTemplateSettingFetching || isTemplateFetching;
if (notReady) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message || "Failed to load content"}</div>;
}
if (markdown.isMessage) {
return (
<div className="markdown-content-container">
<div className="notification is-info">
<h4 className="title is-4">{markdown.title}</h4>
<p>{markdown.content}</p>
</div>
</div>
);
}
return (
<div className="markdown-content-container">
<div className="is-flex is-justify-content-space-between is-align-items-center markdown-content-container-header">
<h1 className="title">{markdown.title === "index" ? indexTitle : markdown.title}</h1>
<PermissionGuard rolesRequired={['admin']}>
<div className="field has-addons">
<button
className="control button is-info is-light"
onClick={() => setSettingModalOpen(true)}
>
Settings
</button>
<Link to={`/markdown/edit/${id}`} className="control button is-primary is-light">
Edit
</Link>
</div>
</PermissionGuard>
</div>
<MarkdownView content={JSON.parse(markdown.content)} template={template}/>
<MarkdownSettingModal
isOpen={isSettingModalOpen}
markdown={markdown}
onClose={() => setSettingModalOpen(false)}
/>
</div>
);
};
export default MarkdownContent;

View File

@@ -0,0 +1,110 @@
.markdown-editor-container {
max-width: 90vw;
margin: 0 auto;
padding: 20px;
background-color: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.markdown-editor-header {
text-align: center;
font-size: 1.8rem;
font-weight: bold;
margin-bottom: 20px;
color: #363636;
}
.markdown-editor-form .field {
margin-bottom: 1.5rem;
}
.markdown-editor-form .label {
font-size: 1rem;
font-weight: 600;
color: #4a4a4a;
}
.markdown-editor-form .input,
.markdown-editor-form .textarea {
font-size: 1rem;
border-radius: 6px;
border: 1px solid #dcdcdc;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
height: 70vh !important;
resize: vertical;
}
.markdown-editor-form .input:focus,
.markdown-editor-form .textarea:focus {
border-color: #3273dc;
box-shadow: 0 0 0 0.125em rgba(50, 115, 220, 0.25);
outline: none;
}
.markdown-editor-form .button {
width: 100%;
font-size: 1.1rem;
padding: 10px 15px;
border-radius: 6px;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.markdown-editor-form .button:hover {
background-color: #276cda;
transform: scale(1.02);
}
.markdown-editor-notification {
text-align: center;
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 20px;
padding: 10px 15px;
border-radius: 6px;
}
.katex-display {
margin: 1em 0;
text-align: center;
}
.katex {
font-size: 1.2rem;
}
code {
font-family: 'Courier New', Courier, monospace;
background-color: #f4f4f4;
padding: 2px 4px;
border-radius: 4px;
}
pre {
background-color: #2d2d2d;
color: #f8f8f2;
padding: 10px;
border-radius: 6px;
overflow-x: auto;
}
.raw-editor {
font-family: monospace;
white-space: pre;
tab-size: 2;
}
.editor-toggle-button {
margin-left: 10px;
}
.json-error {
color: red;
margin-top: 5px;
font-size: 0.9em;
}

View File

@@ -0,0 +1,319 @@
import React, { useContext, useEffect, useState } from "react";
import { AuthContext } from "../../AuthProvider";
import { useNavigate, useParams } from "react-router-dom";
import "katex/dist/katex.min.css";
import "./MarkdownEditor.css";
import PathManager from "../PathManager";
import MarkdownView from "./MarkdownView";
import { useMarkdown, useSaveMarkdown } from "../../utils/queries/markdown-queries";
import {useMarkdownSetting, useUpdateMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
import {useMarkdownTemplate, useMarkdownTemplates} from "../../utils/queries/markdown-template-queries";
import TemplatedEditor from "./TemplatedEditor";
import {useMarkdownTemplateSetting, useUpdateMarkdownTemplateSetting, useCreateMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
import TemplateSelector from "../MarkdownTemplate/TemplateSelector";
import {useCreateMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
const MarkdownEditor = () => {
const { roles } = useContext(AuthContext);
const navigate = useNavigate();
const { strId } = useParams();
const id = Number(strId);
const [title, setTitle] = useState("");
const [content, setContent] = useState({});
const [shortcut, setShortcut] = useState("");
const [pathId, setPathId] = useState(1);
const [isRawMode, setIsRawMode] = useState(false);
const [rawContent, setRawContent] = useState("");
const [jsonError, setJsonError] = useState("");
const [selectedTemplate, setSelectedTemplate] = useState(null);
const {data: markdown, isFetching: isMarkdownFetching, error} = useMarkdown(id);
const saveMarkdown = useSaveMarkdown();
const {data: setting, isFetching: isSettingFetching} = useMarkdownSetting(markdown?.setting_id);
const {data: templateSetting, isFetching: isTemplateSettingFetching} = useMarkdownTemplateSetting(setting?.template_setting_id);
const {data: template, isFetching: isTemplateFetching} = useMarkdownTemplate(templateSetting?.template_id);
const updateTemplateSetting = useUpdateMarkdownTemplateSetting();
const createTemplateSetting = useCreateMarkdownTemplateSetting();
const updateSetting = useUpdateMarkdownSetting();
const {data: templates, isFetching: templatesAreFetching} = useMarkdownTemplates();
const createMarkdownSetting = useCreateMarkdownSetting();
const notReady = isMarkdownFetching || isTemplateFetching || isSettingFetching || isTemplateSettingFetching || templatesAreFetching;
useEffect(() => {
if (markdown) {
setTitle(markdown.title);
if (markdown.isMessage) {
navigate("/");
alert(markdown.content || "Cannot edit this markdown");
return;
}
try {
const parsedContent = JSON.parse(markdown.content);
setContent(parsedContent);
setRawContent(JSON.stringify(parsedContent, null, 2));
setShortcut(markdown.shortcut);
setPathId(markdown.path_id);
} catch (e) {
console.error("Error parsing markdown content:", e);
alert("Error parsing markdown content");
navigate("/");
}
}
}, [markdown, navigate]);
useEffect(() => {
if (template) {
setSelectedTemplate(template);
}
}, [template]);
const handleSave = () => {
if (isRawMode && jsonError) {
alert("Please fix the JSON errors before saving");
return;
}
const saveData = {
title,
content: JSON.stringify(content),
path_id: pathId,
shortcut
};
console.log("markdown", markdown);
console.log(markdown?.id ? "update" : "create",)
if (!markdown?.id) {
saveMarkdown.mutate(
{data: saveData},
{
onSuccess: (newMarkdown) => {
createMarkdownSetting.mutate({}, {
onSuccess: (settingRes) => {
saveMarkdown.mutate({
id: newMarkdown.id,
data: {
setting_id: settingRes.id
}
}, {
onSuccess: () => {
if (selectedTemplate?.id) {
createTemplateSetting.mutate({
template_id: selectedTemplate.id
}, {
onSuccess: (templateSettingRes) => {
updateSetting.mutate({
id: settingRes.id,
data: {
template_setting_id: templateSettingRes.id
}
}, {
onSuccess: () => {
navigate("/");
}
});
}
});
} else {
navigate("/");
}
}
});
}
});
},
onError: () => {
alert("Error saving markdown file");
}
}
);
} else {
console.log("try update");
saveMarkdown.mutate(
{id, data: saveData},
{
onSuccess: () => {
navigate("/markdown/" + id);
},
onError: () => {
alert("Error saving markdown file");
}
}
);
}
};
const toggleEditMode = () => {
if (isRawMode) {
try {
const parsed = JSON.parse(rawContent);
setContent(parsed);
setJsonError("");
setIsRawMode(false);
} catch (e) {
setJsonError("Invalid JSON: " + e.message);
return;
}
} else {
setRawContent(JSON.stringify(content, null, 2));
setIsRawMode(true);
}
};
const handleRawContentChange = (e) => {
const newRawContent = e.target.value;
setRawContent(newRawContent);
try {
const parsed = JSON.parse(newRawContent);
setContent(parsed);
setJsonError("");
} catch (e) {
setJsonError("Invalid JSON: " + e.message);
}
};
const handleTemplateChange = (newTemplate) => {
setSelectedTemplate(newTemplate);
if (templateSetting) {
updateTemplateSetting.mutate({
id: templateSetting.id,
data: {
template_id: newTemplate.id
}
});
}
};
const hasPermission = roles.includes("admin") || roles.includes("creator");
if (!hasPermission)
return <div className="notification is-danger">Permission Denied</div>;
if(notReady) {
console.log("=============");
console.log("isMarkdownFetching", isMarkdownFetching );
console.log("isTemplateFetching", isTemplateFetching );
console.log("isSettingFetching", isSettingFetching );
console.log("isTemplateSettingFetching", isTemplateSettingFetching);
console.log( "TemplatesAreFetching", templatesAreFetching);
console.log("----------------");
return <p>Loading...</p>;
}
if(error)
return <p>{error.message || "Failed to load markdown"}</p>;
return (
<div className="container mt-5 markdown-editor-container">
<h2 className="title is-4">{id ? "Edit Markdown" : "Create Markdown"}</h2>
<div className="columns">
<div className="column is-half">
<form>
<div className="field">
<label className="label">Title</label>
<div className="control">
<input
className="input"
type="text"
placeholder="Enter title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</div>
<div className="field">
<label className="label">Shortcut</label>
<div className="control">
<input
className="input"
type="text"
placeholder="Enter shortcut"
value={shortcut}
onChange={(e) => setShortcut(e.target.value)}
/>
</div>
</div>
<div className="field">
<label className="label">Path</label>
<PathManager
currentPathId={pathId}
onPathChange={setPathId}
/>
</div>
<div className="field">
<div className="control">
<TemplateSelector
template={selectedTemplate || template}
onChange={handleTemplateChange}
/>
</div>
</div>
<div className="field">
<div className="is-flex is-justify-content-space-between is-align-items-center mb-2">
<label className="label mb-0">Content</label>
<button
type="button"
className={`button is-small editor-toggle-button ${isRawMode ? 'is-info' : 'is-light'}`}
onClick={toggleEditMode}
>
{isRawMode ? 'Switch to Template Editor' : 'Switch to Raw Editor'}
</button>
</div>
<div className="control">
{isRawMode ? (
<div>
<p className="help mb-2">
Edit the JSON directly. Make sure it's valid JSON before saving.
</p>
<textarea
className={`textarea raw-editor ${jsonError ? 'is-danger' : ''}`}
style={{height: "70vh"}}
value={rawContent}
onChange={handleRawContentChange}
placeholder="Enter JSON content here"
/>
{jsonError && (
<p className="help is-danger json-error">{jsonError}</p>
)}
</div>
) : (
<TemplatedEditor
style={{height: "40vh"}}
content={content}
template={!markdown?.id ? selectedTemplate : template}
onContentChanged={(k, v) => setContent(
prev => ({...prev, [k]: v})
)}
/>
)}
</div>
</div>
<div className="field">
<div className="control">
<button
className="button is-primary"
type="button"
onClick={handleSave}
disabled={saveMarkdown.isLoading}
>
{saveMarkdown.isLoading ? "Saving..." : "Save"}
</button>
</div>
</div>
</form>
</div>
<div className="column is-half">
<h3 className="subtitle is-5">Preview</h3>
<MarkdownView
content={content}
template={!markdown?.id ? selectedTemplate : template}
height='70vh'
/>
</div>
</div>
</div>
);
};
export default MarkdownEditor;

View File

@@ -0,0 +1,112 @@
.markdown-preview {
padding: 15px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #ffffff;
white-space: normal;
word-wrap: break-word;
overflow-x: auto;
}
.katex-display {
margin: 1em 0;
text-align: center;
}
.katex {
font-size: 1.2rem;
}
code {
font-family: 'Courier New', Courier, monospace;
background-color: #f4f4f4;
padding: 2px 4px;
border-radius: 4px;
}
pre {
background-color: #2d2d2d;
color: #f8f8f2;
padding: 10px;
border-radius: 6px;
overflow-x: auto;
}
.markdown-preview ul,
.markdown-preview ol {
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.markdown-preview ul {
list-style-type: disc;
}
.markdown-preview ol {
list-style-type: decimal;
}
.markdown-preview li {
margin-bottom: 0.5rem;
}
.markdown-preview h1 {
font-size: 2em;
font-weight: bold;
margin-bottom: 0.5rem;
color: #333;
}
.markdown-preview h2 {
font-size: 1.75em;
font-weight: bold;
margin-bottom: 0.5rem;
color: #444;
}
.markdown-preview h3 {
font-size: 1.5em;
font-weight: bold;
margin-bottom: 0.5rem;
color: #555;
}
.markdown-preview h4 {
font-size: 1.25em;
font-weight: bold;
margin-bottom: 0.5rem;
color: #666;
}
.markdown-preview h5 {
font-size: 1.1em;
font-weight: bold;
margin-bottom: 0.5rem;
color: #777;
}
.markdown-preview h6 {
font-size: 1em;
font-weight: bold;
margin-bottom: 0.5rem;
color: #888;
}
.markdown-preview table {
width: 100%;
border-collapse: collapse;
border: 1px solid #ddd;
}
.markdown-preview th,
.markdown-preview td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.markdown-preview th {
background-color: #f4f4f4;
font-weight: bold;
}

View File

@@ -0,0 +1,102 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism";
import "katex/dist/katex.min.css";
import "./MarkdownView.css";
import {useLinks} from "../../utils/queries/markdown-queries";
const Translate = ({variable, value}) => {
if (variable.type.base_type === "markdown" || variable.type.base_type === "string" || variable.type.base_type === "enum") {
return value;
}
if(variable.type.base_type === "list"){
if (!variable.type.extend_type)
return [];
return (value || []).map((item, index) => Translate({
variable: {name: index, type: variable.type.extend_type},
value: item,
})).map((item) => variable.type.definition.iter_layout.replaceAll('<item/>', item)).join("");
}
if(variable.type.base_type === "template"){
return ParseTemplate({
template: variable.type.definition.template,
variables: value
});
}
};
const ParseTemplate = ({template, variables}) => {
if(!template || !Array.isArray(template.parameters)) return '';
const vars = variables || {};
let res = template.layout ?? '';
for (const parameter of template.parameters) {
res = res.replaceAll(`<${parameter.name}/>`, Translate({
variable: parameter,
value: vars[parameter.name]
}));
}
return res;
};
const MarkdownView = ({ content, template, height="auto" }) => {
const {data: links, isLoading} = useLinks();
if (isLoading)
return (<p>Loading...</p>);
const linkDefinitions = "\n<!-- Definitions -->\n" + links.join("\n");
const _template = template || {
parameters: [
{
name: "markdown",
type: {
base_type: "markdown",
definition: {}
}
}
],
layout: "<markdown/>",
title: "default"
};
return (
<div className="markdown-preview" style={{height}}>
<ReactMarkdown
children={ParseTemplate({
template: _template,
variables: content
}) + "\n" + linkDefinitions}
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, rehypeRaw]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={okaidia}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
/>
</div>
);
};
export default MarkdownView;

View File

@@ -0,0 +1,100 @@
import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import "katex/dist/katex.min.css";
import "./MarkdownContent.css";
import MarkdownView from "./MarkdownView";
import { useMarkdown } from "../../utils/queries/markdown-queries";
import { useMarkdownSetting } from "../../utils/queries/markdown-setting-queries";
import { useMarkdownTemplate } from "../../utils/queries/markdown-template-queries";
import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-template-setting-queries";
import { useTree } from "../../utils/queries/tree-queries";
import { getMarkdownIdByPath } from "../../utils/pathUtils";
const StandaloneMarkdownPage = () => {
const location = useLocation();
const [indexTitle, setIndexTitle] = useState(null);
const [markdownId, setMarkdownId] = useState(null);
// Extract path from /pg/project/index -> project/index
const pathString = location.pathname.replace(/^\/pg\//, '');
const { data: tree, isLoading: isTreeLoading } = useTree();
const { data: markdown, isLoading: isMarkdownLoading, error } = useMarkdown(markdownId);
const { data: setting, isFetching: isSettingFetching } = useMarkdownSetting(markdown?.setting_id);
const { data: templateSetting, isFetching: isTemplateSettingFetching } = useMarkdownTemplateSetting(setting?.template_setting_id);
const { data: template, isFetching: isTemplateFetching } = useMarkdownTemplate(templateSetting?.template_id);
// Resolve markdown ID from path using tree
useEffect(() => {
if (tree && pathString) {
const resolvedId = getMarkdownIdByPath(tree, pathString);
setMarkdownId(resolvedId);
}
}, [tree, pathString]);
useEffect(() => {
if (markdown && markdown.title === "index" && pathString) {
const pathParts = pathString.split('/').filter(part => part.length > 0);
if (pathParts.length === 0) {
// Root index: /pg/ or /pg
setIndexTitle("Home");
} else {
// Directory index: /pg/Projects or /pg/Projects/project1
// Use the last directory name as title
const directoryName = pathParts[pathParts.length - 1];
setIndexTitle(directoryName);
}
}
}, [markdown, pathString]);
const notReady = isTreeLoading || isMarkdownLoading || isSettingFetching || isTemplateSettingFetching || isTemplateFetching;
if (notReady) {
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<div>Loading...</div>
</div>
);
}
if (error) {
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<div>Error: {error.message || "Failed to load content"}</div>
</div>
);
}
if (!notReady && !markdownId) {
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<div>Markdown not found for path: {pathString}</div>
</div>
);
}
if (markdown?.isMessage) {
return (
<div style={{ padding: "2rem" }}>
<div className="notification is-info">
<h4 className="title is-4">{markdown.title}</h4>
<p>{markdown.content}</p>
</div>
</div>
);
}
return (
<div style={{ padding: "2rem", maxWidth: "100%", margin: "0 auto" }}>
<div style={{ marginBottom: "2rem" }}>
<h1 className="title">{markdown?.title === "index" ? indexTitle : markdown?.title}</h1>
</div>
{markdown && (
<MarkdownView content={JSON.parse(markdown.content)} template={template} />
)}
</div>
);
};
export default StandaloneMarkdownPage;

View File

@@ -0,0 +1,189 @@
import React, {useState} from "react";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged }) => {
console.log("variable", variable);
const __namespace = `${variable.name}(${variable.type.base_type})`;
const renderField = () => {
switch (variable.type.base_type) {
case "string":
return (
<div className="box has-background-danger-soft">
<label className="label">{__namespace}</label>
<div className="control">
<input
type="text"
className="input"
value={value ?? ""}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
</div>
</div>
);
case "markdown":
return (
<div className="box has-background-primary-soft">
<label className="label">{__namespace}</label>
<div className="control">
<textarea
style={{maxHeight: "10vh"}}
className="textarea"
value={value}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
</div>
</div>
);
case "enum":
return (
<div className="box has-background-info-soft">
<label className="label">{__namespace}</label>
<div className="control">
<div className="select is-fullwidth">
<select
value={value}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
>
{variable.type.definition.enums.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
</div>
</div>
</div>
);
case "list": {
const [cache, setCache] = useState(value || []);
const defaultValue = variable.type.definition.default;
const addItem = () => {
const newCache = [...cache, defaultValue];
setCache(newCache);
onContentChanged(variable.name, newCache);
};
const removeItem = (index) => {
const newCache = cache.filter((_, i) => i !== index);
setCache(newCache);
onContentChanged(variable.name, newCache);
};
const onItemChange = (index, val) => {
const newCache = [...cache];
newCache[index] = val;
setCache(newCache);
onContentChanged(variable.name, newCache);
};
return (
<div className="box has-background-white-soft">
<label className="label">{__namespace}</label>
{cache.map((item, idx) => (
<div className="field is-grouped" key={idx}>
<div className="control is-expanded">
<TemplatedEditorComponent
variable={{ name: idx, type: variable.type.extend_type }}
value={item}
namespace={__namespace}
onContentChanged={(subKey, subVal) => onItemChange(idx, subVal)}
/>
</div>
<div className="control">
<button
className="button is-danger"
type="button"
onClick={() => removeItem(idx)}
>
DELETE
</button>
</div>
</div>
))}
<div className="field">
<div className="control">
<button
className="button is-warning"
type="button"
onClick={addItem}
>
ADD
</button>
</div>
</div>
</div>
);
}
case "template": {
const { data: _template, isFetching: loading } = useMarkdownTemplate(variable.type.definition.template.id);
if (loading) return <p>Loading...</p>;
const _parameters = _template.parameters;
const handleSubChange = (key, val) => {
const updated = { ...(value || {}), [key]: val };
onContentChanged(variable.name, updated);
};
return (
<div className="box has-background-grey-light">
<label className="label">{__namespace}</label>
{_parameters.map((param, i) => (
<div className="field" key={i}>
<TemplatedEditorComponent
variable={param}
value={(value || {})[param.name]}
namespace={__namespace}
onContentChanged={handleSubChange}
/>
</div>
))}
</div>
);
}
default:
return null;
}
};
return <>{renderField()}</>;
};
const TemplatedEditor = ({ content, template, onContentChanged, style }) => {
const tpl = template || {
parameters: [{ name: "markdown", type: { base_type: "markdown", definition: {} } }],
layout: "<markdown/>",
title: "default",
};
return (
<div className="box" style={{
...style,
display: "flex",
flexDirection: "column",
overflow: "hidden"
}}>
<div style={{
flex: 1,
overflowY: "auto",
padding: "1rem"
}}>
{tpl.parameters.map((variable, idx) => (
<TemplatedEditorComponent
key={idx}
variable={variable}
value={content[variable.name]}
namespace={tpl.title}
onContentChanged={onContentChanged}
/>
))}
</div>
</div>
);
};
export default TemplatedEditor;

View File

@@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { useCreateApiKey } from '../../utils/queries/apikey-queries';
const AVAILABLE_ROLES = ['guest', 'creator', 'admin'];
const ApiKeyCreationModal = ({ isOpen, onClose }) => {
const [name, setName] = useState('');
const [roles, setRoles] = useState(['guest']);
const [generatedKey, setGeneratedKey] = useState(null);
const createApiKeyMutation = useCreateApiKey();
const handleAddRole = () => {
const availableRoles = AVAILABLE_ROLES.filter(role => !roles.includes(role));
if (availableRoles.length > 0) {
setRoles([...roles, availableRoles[0]]);
}
};
const handleRoleChange = (index, value) => {
if (roles.includes(value) && roles.findIndex(r => r === value) !== index) {
return;
}
const newRoles = [...roles];
newRoles[index] = value;
setRoles(newRoles);
};
const handleRemoveRole = (index) => {
const newRoles = roles.filter((_, i) => i !== index);
setRoles(newRoles);
};
const handleGenerate = async () => {
if (!name.trim()) {
alert('API key name is required');
return;
}
try {
const result = await createApiKeyMutation.mutateAsync({
name: name.trim(),
roles: roles
});
setGeneratedKey(result);
} catch (error) {
console.error('failed to create api key', error);
alert('failed to create api key');
}
};
const handleCopy = () => {
navigator.clipboard.writeText(generatedKey)
.then(() => alert('API key copied to clipboard'))
.catch(err => console.error('failed to copy api key:', err));
};
const getRemainingRoles = (currentIndex) => {
return AVAILABLE_ROLES.filter(role =>
!roles.find((r, i) => r === role && i !== currentIndex)
);
};
if (!isOpen) return null;
return (
<div className="modal is-active">
<div className="modal-background" onClick={onClose}></div>
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">Create API Key</p>
<button className="delete" aria-label="close" onClick={onClose}></button>
</header>
<section className="modal-card-body">
{!generatedKey ? (
<div>
<div className="field">
<label className="label">Name</label>
<div className="control">
<input
className="input"
type="text"
placeholder="API key name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className="field">
<label className="label">Roles</label>
{roles.map((role, index) => (
<div key={index} className="field has-addons">
<div className="control">
<div className="select">
<select
value={role}
onChange={(e) => handleRoleChange(index, e.target.value)}
>
{getRemainingRoles(index).map(availableRole => (
<option key={availableRole} value={availableRole}>
{availableRole}
</option>
))}
</select>
</div>
</div>
<div className="control">
<button
className="button is-danger"
onClick={() => handleRemoveRole(index)}
disabled={roles.length === 1}
>
Delete
</button>
</div>
</div>
))}
<button
className="button is-info mt-2"
onClick={handleAddRole}
disabled={roles.length === AVAILABLE_ROLES.length}
>
Add Role
</button>
</div>
</div>
) : (
<div>
<div className="notification is-warning">
Please copy your API key immediately! It will only be displayed once!
</div>
<div className="field">
<label className="label">Your API Key:</label>
<div className="control">
<input
className="input"
type="text"
value={generatedKey.key}
readOnly
/>
</div>
</div>
<button className="button is-info" onClick={handleCopy}>
Copy
</button>
</div>
)}
</section>
<footer className="modal-card-foot">
{!generatedKey && (
<button
className="button is-primary"
onClick={handleGenerate}
disabled={createApiKeyMutation.isLoading || !name.trim()}
>
Generate
</button>
)}
<button className="button" onClick={onClose}>Close</button>
</footer>
</div>
</div>
);
};
export default ApiKeyCreationModal;

View File

@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { useRevokeApiKey } from '../../utils/queries/apikey-queries';
const ApiKeyRevokeModal = ({ isOpen, onClose }) => {
const [apiKey, setApiKey] = useState('');
const revokeApiKeyMutation = useRevokeApiKey();
const handleRevoke = async () => {
if (!apiKey.trim()) {
alert('Please enter an API key');
return;
}
try {
await revokeApiKeyMutation.mutateAsync(apiKey);
alert('API key revoked successfully');
onClose();
} catch (error) {
console.error('Failed to revoke API key:', error);
alert('Failed to revoke API key');
}
};
if (!isOpen) return null;
return (
<div className="modal is-active">
<div className="modal-background" onClick={onClose}></div>
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">Revoke API Key</p>
<button className="delete" aria-label="close" onClick={onClose}></button>
</header>
<section className="modal-card-body">
<div className="field">
<label className="label">API Key</label>
<div className="control">
<input
className="input"
type="text"
placeholder="Enter API key to revoke"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</div>
</section>
<footer className="modal-card-foot">
<button
className="button is-danger"
onClick={handleRevoke}
disabled={revokeApiKeyMutation.isLoading}
>
Revoke
</button>
<button className="button" onClick={onClose}>Cancel</button>
</footer>
</div>
</div>
);
};
export default ApiKeyRevokeModal;

View File

@@ -0,0 +1,42 @@
import React from 'react';
const JsonSchemaModal = ({ isActive, onClose, schema }) => {
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(schema, null, 2));
};
return (
<div className={`modal ${isActive ? 'is-active' : ''}`}>
<div className="modal-background" onClick={onClose}></div>
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">JSON Schema</p>
<button className="delete" aria-label="close" onClick={onClose}></button>
</header>
<section className="modal-card-body">
<div className="field">
<div className="control">
<textarea
className="textarea"
value={JSON.stringify(schema, null, 2)}
readOnly
style={{ height: "50vh" }}
/>
</div>
</div>
</section>
<footer className="modal-card-foot">
<button className="button is-primary" onClick={handleCopy}>
<span className="icon">
<i className="fas fa-copy"></i>
</span>
<span>copy</span>
</button>
<button className="button" onClick={onClose}>close</button>
</footer>
</div>
</div>
);
};
export default JsonSchemaModal;

View File

@@ -0,0 +1,86 @@
import {useCreateMarkdownSetting, useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
import {useSaveMarkdown} from "../../utils/queries/markdown-queries";
import React, {useState} from "react";
import MarkdownTemplateSettingPanel from "../Settings/MarkdownSettings/MarkdownTemplateSettingPanel";
import MarkdownPermissionSettingPanel from "../Settings/MarkdownSettings/MarkdownPermissionSettingPanel";
const MarkdownSettingModal = ({isOpen, markdown, onClose}) => {
const {data: markdownSetting, isFetching: markdownSettingIsFetching} = useMarkdownSetting(markdown?.setting_id || 0);
const createMarkdownSetting = useCreateMarkdownSetting();
const updateMarkdown = useSaveMarkdown();
const [activeTab, setActiveTab] = useState("template");
const handleCreateMarkdownSetting = () => {
createMarkdownSetting.mutate({}, {
onSuccess: (res) => {
updateMarkdown.mutate({
id: markdown.id,
data: {
setting_id: res.id
}
});
}
});
};
if(markdownSettingIsFetching)
return(<p>Loading...</p>);
return (
<div className={`modal ${isOpen ? "is-active" : ""}`}>
<div className="modal-background" onClick={onClose} />
<div className="modal-card" style={{width: "60vw"}}>
<header className="modal-card-head">
<p className="modal-card-title">Markdown Settings</p>
<button
className="delete"
type="button"
aria-label="close"
onClick={onClose}
/>
</header>
{
markdownSetting ? (
<section className="modal-card-body">
<div className="tabs">
<ul>
<li className={activeTab==="template" ? "is-active" : ""}>
<a onClick={() => setActiveTab("template")}>Template</a>
</li>
<li className={activeTab==="permission" ? "is-active" : ""}>
<a onClick={() => setActiveTab("permission")}>Permission</a>
</li>
</ul>
</div>
{activeTab === "template" && (
<MarkdownTemplateSettingPanel
markdownSetting={markdownSetting}
onClose={onClose}
/>
)}
{activeTab === "permission" && (
<MarkdownPermissionSettingPanel
markdownSetting={markdownSetting}
onClose={onClose}
/>
)}
</section>
) : (
<section className="modal-card-body">
<button
className="button is-primary"
type="button"
onClick={handleCreateMarkdownSetting}
>
Create Markdown Setting
</button>
</section>
)
}
</div>
</div>
);
};
export default MarkdownSettingModal;

View File

@@ -0,0 +1,77 @@
import {useUpdatePath} from "../../utils/queries/path-queries";
import {useCreatePathSetting, usePathSetting} from "../../utils/queries/path-setting-queries";
import WebhookSettingPanel from "../Settings/PathSettings/WebhookSettingPanel";
import React, {useState} from "react";
const PathSettingModal = ({ isOpen, path, onClose }) => {
const settingId = path?.setting_id || 0;
const {data: pathSetting, isLoading: isPathSettingLoading} = usePathSetting(settingId);
const createPathSetting = useCreatePathSetting();
const updatePath = useUpdatePath();
const [activeTab, setActiveTab] = useState("webhook");
const handleCreatePathSetting = () => {
createPathSetting.mutate({}, {
onSuccess: (res) => {
updatePath.mutate({id: path.id, data: {setting_id: res.id}});
}
});
};
if(settingId && isPathSettingLoading)
return (<p>Loading...</p>);
return (
<div className={`modal ${isOpen ? "is-active" : ""}`}>
<div className="modal-background" onClick={onClose} />
<div className="modal-card" style={{width: "60vw"}}>
<header className="modal-card-head">
<p className="modal-card-title">Path Settings</p>
<button
type="button"
className="delete"
aria-label="close"
onClick={onClose}
/>
</header>
{!pathSetting ? (
<section className="modal-card-body">
<button
type="button"
className="button is-primary"
onClick={handleCreatePathSetting}
>
Create Path Setting
</button>
</section>
) : (
<section className="modal-card-body">
<div className="tabs">
<ul>
<li className={activeTab === "webhook" ? "is-active" : ""}>
<a onClick={() => setActiveTab("webhook")}>Webhook</a>
</li>
<li className={activeTab === "template" ? "is-active" : ""}>
<a onClick={() => setActiveTab("template")}>Template</a>
</li>
</ul>
</div>
{activeTab === "webhook" && (
<WebhookSettingPanel
pathSetting={pathSetting}
onClose={onClose}
/>
)}
{activeTab === "template" && (
<div></div>
)}
</section>
)}
</div>
</div>
);
};
export default PathSettingModal;

View File

@@ -0,0 +1,64 @@
.main-navigation {
background-color: #333;
color: white;
padding: 1rem;
}
.dropdown-option {
margin-top: 0.2rem;
}
.main-navigation .navbar {
background-color: #333 !important;
}
.main-navigation .navbar-item {
color: white !important;
font-weight: bold;
}
.main-navigation .navbar-item:hover {
background-color: #444 !important;
color: #f5f5f5 !important;
}
.navbar-end .navbar-item {
margin-right: 1rem;
}
.main-navigation .button {
margin-left: 0.5rem;
}
.main-navigation .button.is-primary {
background-color: #007BFF;
border-color: #0056b3;
}
.main-navigation .button.is-primary:hover {
background-color: #0056b3;
border-color: #003f7f;
}
.main-navigation .button.is-danger {
background-color: #e3342f;
border-color: #a21c1c;
}
.main-navigation .button.is-danger:hover {
background-color: #a21c1c;
border-color: #6c0e0e;
}
.main-navigation .navbar-burger {
color: white;
}
.main-navigation .navbar-burger:hover {
background-color: #444;
}
.main-navigation .navbar-item:not(:last-child) {
border-right: 1px solid #555;
padding-right: 1rem;
}

View File

@@ -0,0 +1,217 @@
import React, {useContext, useState} from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "../../AuthProvider";
import "bulma/css/bulma.min.css";
import {useConfig} from "../../ConfigProvider";
import "./MainNavigation.css";
import ApiKeyCreationModal from "../Modals/ApiKeyCreationModal";
import ApiKeyRevokeModal from "../Modals/ApiKeyRevokeModal";
const MainNavigation = () => {
const { user, login, logout } = useContext(AuthContext);
const config = useConfig();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
if (config===undefined) {
return <div>Loading ...</div>;
}
const handleLoadBackup = async () => {
try{
const input = document.createElement("input");
input.type = "file";
input.accept=".zip";
input.onchange = async (event) => {
const file = event.target.files[0];
if(!file)
return;
const formData = new FormData();
formData.append("file", file);
try{
const response = await fetch(
`${config.BACKEND_HOST}/api/backup/load`, {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
},
body: formData
}
);
if(response.ok){
const result = await response.json();
alert("Backup loaded");
} else {
const error = await response.json();
alert(`failed to load ${error.error}`);
}
} catch (error) {
console.error(error);
alert("error when loading backup");
}
};
input.click();
} catch (error) {
console.error(error);
alert(`Unexpected error`);
}
}
const handleGetBackup = async () => {
try{
const response = await fetch(
`${config.BACKEND_HOST}/api/backup/`, {
method: "GET",
headers: {
Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
}
}
);
if(response.ok){
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
const contentDisposition = response.headers.get("Content-Disposition");
let filename = "backup.zip";
if (contentDisposition) {
const match = contentDisposition.match(/filename="?([^"]+)"?/);
if (match && match[1]) {
filename = match[1];
}
}
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
}else{
alert("Failed to get backup");
}
} catch(err){
console.log(err);
alert("An error occurred while retrieving backup");
}
};
return (
<>
<nav className="navbar is-dark" role="navigation" aria-label="main navigation">
<div className="navbar-brand">
<Link className="navbar-item" to="/">
<img src="/icons/logo.png" alt="Logo" style={{ height: "40px", marginRight: "10px" }} />
Home
</Link>
<a
role="button"
className="navbar-burger"
aria-label="menu"
aria-expanded="false"
data-target="navbarBasicExample"
onClick={(e) => {
e.currentTarget.classList.toggle("is-active");
document
.getElementById("navbarBasicExample")
.classList.toggle("is-active");
}}
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" className="navbar-menu">
<div className="navbar-start">
<a
href="https://mail.hangman-lab.top"
className="navbar-item"
target="_blank"
rel="noopener noreferrer"
>
MailBox
</a>
<a
href="https://git.hangman-lab.top"
className="navbar-item"
target="_blank"
rel="noopener noreferrer"
>
Git
</a>
</div>
<div className="navbar-end">
{user && user.profile ? (
<div className="navbar-item has-dropdown is-hoverable">
<div className="buttons">
<span
className="button is-primary is-light"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
{user.profile.name}
</span>
<div className={`navbar-dropdown ${isDropdownOpen ? "is-active" : ""}`}>
<button
className="button is-primary dropdown-option"
onClick={handleGetBackup}
type="button"
>
Get Backup
</button>
<button
className="button is-primary dropdown-option"
onClick={handleLoadBackup}
type="button"
>
Load Backup
</button>
<button
className="button is-info dropdown-option"
onClick={() => setIsApiKeyModalOpen(true)}
type="button"
>
Create API Key
</button>
<button
className="button is-warning dropdown-option"
onClick={() => setIsRevokeModalOpen(true)}
type="button"
>
Revoke API Key
</button>
<button
className="button is-danger dropdown-option"
onClick={logout}
type="button"
>
Logout
</button>
</div>
</div>
</div>
) : (
<div className="navbar-item">
<button
className="button is-primary"
onClick={login}
type="button"
>
Login
</button>
</div>
)}
</div>
</div>
</nav>
<ApiKeyCreationModal
isOpen={isApiKeyModalOpen}
onClose={() => setIsApiKeyModalOpen(false)}
/>
<ApiKeyRevokeModal
isOpen={isRevokeModalOpen}
onClose={() => setIsRevokeModalOpen(false)}
/>
</>
);
};
export default MainNavigation;

View File

@@ -0,0 +1,91 @@
import {Link, useNavigate} from "react-router-dom";
import PermissionGuard from "../PermissionGuard";
import React, {useState} from "react";
import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
import {useDeleteMarkdown} from "../../utils/queries/markdown-queries";
import {useDeleteMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
const [isMarkdownSettingModalOpen, setIsMarkdownSettingModalOpen] = useState(false);
const navigate = useNavigate();
const deleteMarkdown = useDeleteMarkdown();
const deleteMarkdownSetting = useDeleteMarkdownSetting();
const handleDeleteMarkdown = async () => {
if (!window.confirm(`delete markdown "${markdown.title}" ? this action cannot be undone.`)) {
return;
}
try {
await deleteMarkdown.mutateAsync(markdown.id);
if (window.location.pathname === `/markdown/${markdown.id}`) {
navigate('/');
}
} catch (error) {
alert('failed: ' + (error.message || 'unknown error'));
}
};
return (
<li key={markdown.id}>
<div className="is-clickable field has-addons">
<span className="markdown-name has-text-weight-bold control">
<Link to={`/markdown/${markdown.id}`} className="is-link markdown-node">
{markdown.title}
</Link>
</span>
<PermissionGuard rolesRequired={['admin']}>
<p className="control">
<button
className="button is-small is-success"
onClick={() => setIsMarkdownSettingModalOpen(true)}
type="button"
>
<span className="icon">
<i className="fas fa-cog"/>
</span>
</button>
</p>
<p className="control">
<button
className="button is-small is-danger"
onClick={handleDeleteMarkdown}
type="button"
disabled={deleteMarkdown.isLoading || deleteMarkdownSetting.isLoading}
>
<span className="icon">
<i className="fas fa-trash"/>
</span>
</button>
</p>
<div
className="control is-flex is-flex-direction-column is-align-items-center"
style={{marginLeft: "0.5rem"}}
>
<button
className="button is-small mb-1 move-forward"
style={{height: "1rem", padding: "0.25rem"}}
onClick={() => handleMoveMarkdown(markdown, "forward")}
type="button"
>
</button>
<button
className="button is-small mb-1 move-backward"
style={{height: "1rem", padding: "0.25rem"}}
onClick={() => handleMoveMarkdown(markdown, "backward")}
type="button"
>
</button>
</div>
<MarkdownSettingModal
isOpen={isMarkdownSettingModalOpen}
markdown={markdown}
onClose={() => setIsMarkdownSettingModalOpen(false)}
/>
</PermissionGuard>
</div>
</li>
);
}
export default MarkdownNode;

View File

@@ -0,0 +1,81 @@
.menu {
background-color: white;
border: 1px solid #eee;
}
.path-node {
margin-left: 1rem;
}
.path-toggle {
background-color: #e4a4d8;
width: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 1rem;
font-size: 1rem;
color: #ffffff;
}
.path-node > .has-text-weight-bold {
display: inline-flex;
align-items: center;
padding: 0.25rem 0;
}
.path-node .is-expanded {
font-size: 1rem;
font-weight: bold;
color: #363636;
}
.path-node .menu-list-item {
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.path-node .menu-list-item:hover {
background-color: #f0f0f0;
color: #00d1b2;
}
.loading-indicator {
color: #00d1b2;
font-size: 0.9rem;
margin-left: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
margin-left: auto;
}
.path-node-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.move-forward {
background-color: #c0fff0 !important;
border-color: #c0fbc0 !important;
color: #b0ffa0 !important;
}
.move-forward:hover {
background-color: #a0ffa0;
border-color: #0afb0c;
}
.move-backward {
background-color: #a0a0ff !important;
border-color: #c0c0ff !important;
color: #0af0fc !important;
}
.move-backward:hover {
background-color: #a4a4f0;
border-color: #facaff;
}

View File

@@ -0,0 +1,234 @@
import React, {useState} from "react";
import { useSelector, useDispatch } from 'react-redux';
import { toggleNodeExpansion } from '../../store/navigationSlice';
import { Link } from "react-router-dom";
import PermissionGuard from "../PermissionGuard";
import "./PathNode.css";
import {useDeletePath, useMovePath, useUpdatePath} from "../../utils/queries/path-queries";
import {useIndexMarkdown, useMoveMarkdown} from "../../utils/queries/markdown-queries";
import MarkdownNode from "./MarkdownNode";
import PathSettingModal from "../Modals/PathSettingModal";
const PathNode = ({ path, isRoot = false }) => {
const [isPathSettingModalOpen, setIsPathSettingModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(path.name || "");
const expandedNodes = useSelector(state => state.navigation.expandedNodes);
const dispatch = useDispatch();
const isExpanded = isRoot || expandedNodes[path.id];
const deletePath = useDeletePath();
const updatePath = useUpdatePath();
const {data: indexMarkdown} = useIndexMarkdown(path.id);
const movePath = useMovePath();
const moveMarkdown = useMoveMarkdown();
const expand = () => {
if (!isExpanded) {
dispatch(toggleNodeExpansion(path.id));
}
};
const toggleExpand = () => {
dispatch(toggleNodeExpansion(path.id));
};
const handleSave = () => {
updatePath.mutate({id: path.id, data: {name: newName}}, {
onSuccess: () => setIsEditing(false),
onError: err => alert("failed to update this path"),
})
};
const handleDelete = () => {
if(window.confirm("Are you sure?")) {
deletePath.mutate(path.id, {
onError: err => alert("failed to delete this path"),
})
}
};
const handleEdit = () => {
setIsEditing(true);
};
const handleMovePath = (pth, direction) => {
movePath.mutate({path: pth, direction: direction}, {
onError: () => alert("failed to move this path"),
});
};
const handleMoveMarkdown = (md, direction) => {
moveMarkdown.mutate({markdown: md, direction: direction}, {
onError: () => alert("failed to move this markdown"),
})
};
const childPaths = path.children.filter(x => x.type==="path");
const sortedPaths = childPaths
? childPaths.slice().sort((a, b) => a.order.localeCompare(b.order))
: [];
const markdowns = path.children.filter(x => x.type==="markdown");
const sortedMarkdowns = markdowns
? markdowns.filter(md => md.title !== "index").sort((a, b) => a.order.localeCompare(b.order))
: [];
if(isRoot)
return (
<ul className="menu-list">
{sortedPaths.map((path) => (
<PathNode
key={path.id}
path={path}
isRoot={false}
onSave={handleSave}
onDelete={handleDelete}
/>
))}
{sortedMarkdowns.filter(md => md.title !== "index").map((markdown) => (
<MarkdownNode
markdown={markdown}
handleMoveMarkdown={handleMoveMarkdown}
key={markdown.id}
/>
))}
</ul>
);
return (
<li key={path.id}>
<div className="path-node-header field has-addons">
<span className="control has-text-weight-bold path-toggle" onClick={isRoot ? undefined : toggleExpand}>
{isExpanded ? "-" : "+"}
</span>
{isEditing ? (
<div className="control has-icons-left">
<input
className="input is-small path-edit-input"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
) : (
<span
className="path-name has-text-weight-bold control"
onClick={isRoot ? undefined : expand}
>
{
indexMarkdown ? (
<Link to={`/markdown/${indexMarkdown.id}`} className="is-link index-path-node">
{path.name}
</Link>
) : (
<a className="is-link path-node">{path.name}</a>
)
}
</span>
)}
<PermissionGuard rolesRequired={["admin"]}>
<div className="field has-addons actions control is-justify-content-flex-end">
<p className="control">
<button
className="button is-small is-success"
onClick={() => {
setIsPathSettingModalOpen(true);
}}
type="button"
>
<span className="icon">
<i className="fas fa-cog"/>
</span>
</button>
</p>
{isEditing ? (
<p className="control">
<button
className="button is-small is-success"
onClick={handleSave}
type="button"
>
<span className="icon">
<i className="fas fa-check"></i>
</span>
</button>
</p>
) : (
<p className="control">
<button
className="button is-small is-info"
onClick={handleEdit}
type="button"
>
<span className="icon">
<i className="fas fa-pen"></i>
</span>
</button>
</p>
)}
<p className="control">
<button
className="button is-danger is-small"
onClick={handleDelete}
type="button">
<span className="icon">
<i className="fas fa-trash"></i>
</span>
</button>
</p>
<div
className="control is-flex is-flex-direction-column is-align-items-center"
style={{marginLeft: "0.5rem"}}
>
<button
className="button is-small mb-1 move-forward"
style={{height: "1rem", padding: "0.25rem"}}
onClick={() => handleMovePath(path, "forward")}
type="button"
>
</button>
<button
className="button is-small mb-1 move-backward"
style={{height: "1rem", padding: "0.25rem"}}
onClick={() => handleMovePath(path, "backward")}
type="button"
>
</button>
</div>
</div>
<PathSettingModal
isOpen={isPathSettingModalOpen}
path={path}
onClose={() => setIsPathSettingModalOpen(false)}
/>
</PermissionGuard>
</div>
{isExpanded && (
<ul>
{sortedPaths.map((child) => (
<PathNode
key={child.id}
path={child}
/>
))}
{sortedMarkdowns.filter(md => md.title !== "index").map((markdown) => (
<MarkdownNode
markdown={markdown}
handleMoveMarkdown={handleMoveMarkdown}
key={markdown.id}
/>
))}
</ul>
)}
</li>
);
};
export default PathNode;

View File

@@ -0,0 +1,82 @@
.menu {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background-color: #f9f9f9;
height: 90vh;
overflow-y: hidden;
min-width: 15vw;
}
.menu-label {
font-size: 1.25rem;
color: #363636;
margin-bottom: 1rem;
}
.menu-list {
margin-left: 0;
height: 100%;
overflow-y: auto;
}
.menu-list-item {
padding: 0.5rem;
font-size: 1rem;
color: #4a4a4a;
border-bottom: 1px solid #ddd;
}
.menu-list-item:last-child {
border-bottom: none;
}
.menu-list-item:hover {
background-color: #f5f5f5;
color: #00d1b2;
}
.has-text-weight-bold {
font-weight: 600;
cursor: pointer;
}
.is-clickable {
color: #3273dc;
text-decoration: underline;
}
.is-clickable:hover {
color: #2759a7;
}
.index-path-node {
background-color: #cceeee !important;
}
.path-node {
background-color: #ccffec !important;
}
.markdown-node {
background-color: #fffbe6 !important;
}
.tabs ul {
display: flex;
margin-bottom: 0;
padding-left: 0;
}
.tabs li {
flex: 1;
list-style: none;
}
.side-nav {
min-width: 15vw;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -0,0 +1,55 @@
import React, { useContext, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { setSelectedTab } from '../../store/navigationSlice';
import "./SideNavigation.css";
import TreeTab from "./SideTabs/TreeTab";
import TemplateTab from "./SideTabs/TemplateTab";
import { AuthContext } from "../../AuthProvider";
const SideNavigation = () => {
const { roles } = useContext(AuthContext);
const selectedTab = useSelector(state => state.navigation.selectedTab);
const dispatch = useDispatch();
const allTabs = [
{ id: "tree", label: "Tree", component: <TreeTab /> },
{ id: "templates", label: "Templates", component: <TemplateTab /> },
];
const visibleTabs = roles.includes("admin")
? allTabs
: allTabs.filter(tab => tab.id === "tree");
useEffect(() => {
if (!visibleTabs.find(tab => tab.id === selectedTab)) {
dispatch(setSelectedTab(visibleTabs[0]?.id || ""));
}
}, [visibleTabs, selectedTab, dispatch]);
const current = visibleTabs.find(t => t.id === selectedTab);
return (
<aside className="side-nav">
<div className="tabs is-small">
<ul>
{visibleTabs.map(tab => (
<li
key={tab.id}
className={tab.id === selectedTab ? "is-active" : ""}
>
<a onClick={() => dispatch(setSelectedTab(tab.id))}>
{tab.label}
</a>
</li>
))}
</ul>
</div>
<div className="tab-content">
{current?.component}
</div>
</aside>
);
};
export default SideNavigation;

View File

@@ -0,0 +1,174 @@
import React, { useState } from "react";
import { useMarkdownTemplates } from "../../../utils/queries/markdown-template-queries";
import PermissionGuard from "../../PermissionGuard";
import { useNavigate } from "react-router-dom";
import JsonSchemaModal from "../../Modals/JsonSchemaModal";
const TemplateTab = () => {
const { data: templates, isLoading, error } = useMarkdownTemplates();
const [keyword, setKeyword] = useState("");
const [selectedSchema, setSelectedSchema] = useState(null);
const navigate = useNavigate();
const filteredTemplates = templates?.filter(template =>
template.title.toLowerCase().includes(keyword.toLowerCase())
);
const handleTemplateClick = (templateId) => {
navigate(`/template/edit/${templateId}`);
};
const generateJsonSchema = (template) => {
const schema = {
type: "object",
properties: {},
$defs: {}
};
const generateTypeSchema = (param, defName) => {
switch (param.type.base_type) {
case "string":
return {
type: "string"
};
case "markdown":
return {
type: "string",
description: "Markdown content"
};
case "enum":
return {
type: "string",
enum: param.type.definition.enums
};
case "list":
if (param.type.extend_type.base_type === "string") {
return {
type: "array",
items: {
type: "string"
}
};
} else if (param.type.extend_type.base_type === "list" ||
param.type.extend_type.base_type === "template") {
const itemsDefName = `${defName}_items`;
schema.$defs[itemsDefName] = generateTypeSchema(
{ type: param.type.extend_type },
itemsDefName
);
return {
type: "array",
items: {
$ref: `#/$defs/${itemsDefName}`
}
};
} else {
return {
type: "array",
items: generateTypeSchema(
{ type: param.type.extend_type },
`${defName}_items`
)
};
}
case "template":
const nestedTemplate = templates.find(t => t.id === param.type.definition.template.id);
if (nestedTemplate) {
const nestedSchema = {
type: "object",
properties: {}
};
nestedTemplate.parameters.forEach(nestedParam => {
nestedSchema.properties[nestedParam.name] = generateTypeSchema(
nestedParam,
`${defName}_${nestedParam.name}`
);
});
return nestedSchema;
} else {
return {
type: "object",
properties: {}
};
}
default:
return {
type: "object",
properties: {}
};
}
};
template.parameters.forEach(param => {
const defName = `param_${param.name}`;
schema.properties[param.name] = generateTypeSchema(param, defName);
});
if (Object.keys(schema.$defs).length === 0) {
delete schema.$defs;
}
return schema;
};
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading templates</p>;
return (
<aside className="menu">
<div className="control is-expanded">
<input
className="input is-small"
type="text"
placeholder="Search templates..."
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<a
href="/template/create"
className="button is-primary is-small is-fullwidth"
style={{ marginBottom: "10px" }}
>
Create New Template
</a>
</PermissionGuard>
<ul className="menu-list">
{filteredTemplates?.map((template) => (
<li key={template.id}>
<div className="is-flex is-justify-content-space-between is-align-items-center">
<span>{template.title}</span>
<div className="field has-addons is-justify-content-flex-end">
<button
className="button is-small control"
onClick={() => handleTemplateClick(template.id)}
type="button"
>
<span className="icon">
<i className="fas fa-edit"></i>
</span>
</button>
<button
className="button is-small control"
onClick={() => setSelectedSchema(generateJsonSchema(template))}
type="button"
>
<span className="icon">
<i className="fas fa-code"></i>
</span>
</button>
</div>
</div>
</li>
))}
</ul>
<JsonSchemaModal
isActive={selectedSchema !== null}
onClose={() => setSelectedSchema(null)}
schema={selectedSchema}
/>
</aside>
);
};
export default TemplateTab;

View File

@@ -0,0 +1,88 @@
import PermissionGuard from "../../PermissionGuard";
import PathNode from "../PathNode";
import React from "react";
import {useTree} from "../../../utils/queries/tree-queries";
import {useDeletePath, useUpdatePath} from "../../../utils/queries/path-queries";
const TreeTab = () => {
const {data: tree, isLoading, error} = useTree();
const deletePath = useDeletePath();
const updatePath = useUpdatePath();
const [keyword, setKeyword] = React.useState("");
const handleDelete = (id) => {
if (window.confirm("Are you sure you want to delete this path?")){
deletePath.mutate(id, {
onError: (err) => {
alert("Failed to delete path");
},
});
}
};
const filterTree = (t, k) => {
if(t === undefined)
return undefined;
if (t.type === "path") {
if (t.name.includes(k)) {
return { ...t };
}
const filteredChildren = (t.children || [])
.map(c => filterTree(c, k))
.filter(Boolean);
if (filteredChildren.length > 0) {
return { ...t, children: filteredChildren };
}
} else if (t.type === "markdown") {
if (t.title.includes(k)) {
return { ...t };
}
}
return undefined;
};
const filteredTree = filterTree(tree, keyword);
const handleSave = (id, newName) => {
updatePath.mutate({ id, data: {name: newName }} , {
onError: (err) => {
alert("Failed to update path");
}
});
};
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading tree</p>;
return (
<aside className="menu">
<div className="control is-expanded">
<input
className="input is-small"
type="text"
placeholder="Search..."
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<a
href="/markdown/create"
className="button is-primary is-small"
>
Create New Markdown
</a>
</PermissionGuard>
{!filteredTree || filteredTree.length === 0 ?
<p>No Result</p> :
<PathNode
key={1}
path={filteredTree}
isRoot={true}
onSave={handleSave}
onDelete={handleDelete}
/>
}
</aside>
);
};
export default TreeTab;

View File

@@ -0,0 +1,39 @@
.path-manager-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dropdown {
display: flex;
align-items: center;
position: relative;
}
.dropdown .dropdown-input {
flex: 1;
}
.dropdown .dropdown-menu {
position: absolute;
top: 100%;
left: 0;
width: 100%;
z-index: 10;
background: white;
border: 1px solid #ddd;
border-radius: 0.25rem;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
}
.dropdown-item {
padding: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
.dropdown-item:hover {
background-color: #f0f0f0;
}

View File

@@ -0,0 +1,180 @@
import React, {useEffect, useState, useRef, useContext} from "react";
import {useCreatePath, usePaths} from "../utils/queries/path-queries";
import { useQueryClient } from "@tanstack/react-query";
import "./PathManager.css";
import {fetch_} from "../utils/request-utils";
import {ConfigContext} from "../ConfigProvider";
const PathManager = ({ currentPathId = 1, onPathChange }) => {
const [currentFullPath, setCurrentFullPath] = useState([{ name: "Root", id: 1 }]);
const [searchTerm, setSearchTerm] = useState("");
const [dropdownActive, setDropdownActive] = useState(false);
const inputRef = useRef();
const queryClient = useQueryClient();
const { data: subPaths, isLoading: isSubPathsLoading, error: subPathsError } = usePaths(currentPathId);
const createPath = useCreatePath();
const config = useContext(ConfigContext).config;
const buildFullPath = async (pathId) => {
const path = [];
let current_id = pathId;
while (current_id) {
try {
const pathData = await queryClient.fetchQuery({
queryKey: ["path", current_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/path/${current_id}`)
});
if (!pathData) break;
path.unshift({ name: pathData.name, id: pathData.id });
current_id = pathData.parent_id;
} catch (error) {
console.error(`Failed to fetch path with id ${current_id}:`, error);
break;
}
}
return path;
};
useEffect(() => {
const init = async () => {
const path = await buildFullPath(currentPathId);
setCurrentFullPath(path);
};
init();
}, [currentPathId, queryClient]);
const handlePathClick = (pathId, pathIndex) => {
const newPath = currentFullPath.slice(0, pathIndex + 1);
setCurrentFullPath(newPath);
onPathChange(pathId);
};
const handleSubPathSelect = (subPath) => {
const updatedPath = [...currentFullPath, { name: subPath.name, id: subPath.id }];
setCurrentFullPath(updatedPath);
onPathChange(subPath.id);
setSearchTerm("");
setDropdownActive(false);
};
const handleAddDirectory = () => {
if (!searchTerm.trim()) {
alert("Directory name cannot be empty.");
return;
}
createPath.mutate(
{ name: searchTerm.trim(), parent_id: currentPathId },
{
onSuccess: (newDir) => {
queryClient.setQueryData({queryKey: ["path", newDir.id]}, newDir);
queryClient.invalidateQueries({queryKey: ["paths", currentPathId]});
setSearchTerm("");
alert("Directory created successfully.");
},
onError: (error) => {
console.error("Failed to create directory:", error);
alert("Failed to create directory.");
},
}
);
};
const handleInputFocus = () => setDropdownActive(true);
const handleInputBlur = () => {
setTimeout(() => setDropdownActive(false), 150);
};
const filteredSubPaths = subPaths
? subPaths.filter((path) =>
path.name.toLowerCase().includes(searchTerm.toLowerCase())
)
: [];
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddDirectory();
}
};
return (
<div className="path-manager">
<div className="path-manager-header field has-addons">
<div className="current-path control">
{currentFullPath.map((path, index) => (
<span
key={path.id}
className="tag is-clickable is-link is-light"
onClick={() => handlePathClick(path.id, index)}
>
{path.name + "/"}
</span>
))}
</div>
<div className="control">
<span>&nbsp;&nbsp;&nbsp;&nbsp;</span>
</div>
</div>
<div className="path-manager-body">
<div className="field has-addons">
<div className="control">
<input
ref={inputRef}
className="input is-small"
type="text"
placeholder="Search or create directory"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
/>
</div>
<div className="control">
<button
className="button is-small is-primary"
onClick={handleAddDirectory}
disabled={isSubPathsLoading || !searchTerm.trim()}
type="button"
>
Create "{searchTerm}"
</button>
</div>
</div>
{dropdownActive && (
<div className="dropdown is-active">
<div className="dropdown-menu">
<div className="dropdown-content">
{filteredSubPaths.length > 0 ? (
filteredSubPaths.map((subPath) => (
<a
key={subPath.id}
className="dropdown-item"
onClick={() => handleSubPathSelect(subPath)}
>
{subPath.name}
</a>
))
) : (
<div className="dropdown-item">No matches found</div>
)}
</div>
</div>
</div>
)}
{isSubPathsLoading && <p>Loading...</p>}
{subPathsError && <p>Error loading subdirectories.</p>}
</div>
</div>
);
};
export default PathManager;

View File

@@ -0,0 +1,14 @@
import {useContext} from "react";
import {AuthContext} from "../AuthProvider";
const PermissionGuard = ({rolesRequired, children}) => {
const { roles = [] } = useContext(AuthContext);
const hasPermission = rolesRequired.some((role) => roles.includes(role));
if (!hasPermission) {
return null;
}
return children;
}
export default PermissionGuard;

View File

@@ -0,0 +1,91 @@
import {
useCreateMarkdownPermissionSetting,
useMarkdownPermissionSetting,
useUpdateMarkdownPermissionSetting
} from "../../../utils/queries/markdown-permission-setting-queries";
import React, {useEffect, useState} from "react";
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries";
const MarkdownPermissionSettingPanel = ({markdownSetting, onClose}) => {
const {data: setting, isFetching: settingIsFetching } = useMarkdownPermissionSetting(markdownSetting?.permission_setting_id);
const [permission, setPermission] = useState("");
const createMarkdownPermissionSetting = useCreateMarkdownPermissionSetting();
const updateMarkdownSetting = useUpdateMarkdownSetting();
const updateMarkdownPermissionSetting = useUpdateMarkdownPermissionSetting();
useEffect(() => {
if (setting && setting.permission !== undefined && setting.permission !== null) {
setPermission(setting.permission);
}
}, [setting]);
const handleCreatePermissionSetting = () => {
createMarkdownPermissionSetting.mutate({permission: null}, {
onSuccess: (data) => {
updateMarkdownSetting.mutate({
id: markdownSetting.id,
data: {
permission_setting_id: data.id,
}
});
}
});
};
const handleSaveMarkdownPermissionSetting = () => {
const permissionValue = permission === "" ? null : permission;
updateMarkdownPermissionSetting.mutate({
id: setting.id,
data: {
permission: permissionValue,
}
}, {
onSuccess: () => alert("Saved"),
onError: () => alert("Failed to save"),
});
onClose();
};
if (settingIsFetching) {
return (<p>Loading...</p>);
}
return setting ? (
<div className="box" style={{marginTop: "1rem"}}>
<h4 className="title is-5">Permission Setting</h4>
<div className="field">
<label className="label">Permission</label>
<div className="select is-fullwidth">
<select
value={permission}
onChange={(e) => setPermission(e.target.value)}
>
<option value="">(None)</option>
<option value="public">public</option>
<option value="protected">protected</option>
<option value="private">private</option>
</select>
</div>
</div>
<button
className="button is-primary"
type="button"
onClick={handleSaveMarkdownPermissionSetting}
>
Save Permission Setting
</button>
</div>
) : (
<button
className="button is-primary"
type="button"
onClick={handleCreatePermissionSetting}
>
Create Permission Setting
</button>
);
};
export default MarkdownPermissionSettingPanel;

View File

@@ -0,0 +1,95 @@
import {
useMarkdownTemplate,
useMarkdownTemplates,
} from "../../../utils/queries/markdown-template-queries";
import {
useCreateMarkdownTemplateSetting,
useMarkdownTemplateSetting,
useUpdateMarkdownTemplateSetting
} from "../../../utils/queries/markdown-template-setting-queries";
import React, {useEffect, useState} from "react";
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries";
const MarkdownTemplateSettingPanel = ({markdownSetting, onClose}) => {
const {data: setting, isFetching: settingIsFetching } = useMarkdownTemplateSetting(markdownSetting?.template_setting_id);
const {data: templates, isFetching: templatesAreFetching}=useMarkdownTemplates();
const {data: template, isFetching: templateIsFetching} = useMarkdownTemplate(setting?.template_id);
const [selectedTemplateId, setSelectedTemplateId] = useState(template?.id ?? undefined);
const createMarkdownTemplateSetting = useCreateMarkdownTemplateSetting();
const updateMarkdownSetting = useUpdateMarkdownSetting();
const updateMarkdownTemplateSetting = useUpdateMarkdownTemplateSetting();
const handleCreateTemplateSetting = () => {
createMarkdownTemplateSetting.mutate({}, {
onSuccess: (data) => {
updateMarkdownSetting.mutate({
id: markdownSetting.id,
data: {
template_setting_id: data.id,
}
});
}
});
};
const handleSaveMarkdownTemplateSetting = () => {
updateMarkdownTemplateSetting.mutate({
id: setting.id,
data: {
template_id: selectedTemplateId,
}
}, {
onSuccess: () => alert("Saved"),
onError: () => alert("Failed to save"),
});
onClose();
};
useEffect(() => {
if(template?.id && selectedTemplateId === undefined) {
setSelectedTemplateId(template?.id ?? undefined);
}
},[template, selectedTemplateId]);
if (settingIsFetching || templatesAreFetching || templatesAreFetching || templateIsFetching) {
return (<p>Loading...</p>);
}
return setting ? (
<div className="box" style={{marginTop: "1rem"}}>
<h4 className="title is-5">Template Setting</h4>
<div className="field">
<label className="label">Use Template</label>
<div className="select is-fullwidth">
<select
value={selectedTemplateId}
onChange={(e) => {
setSelectedTemplateId(e.target.value);
}}
>
<option value="">(default)</option>
{templates.map((_template, index) => (
<option key={index} value={_template.id}>{_template.title}</option>
))}
</select>
</div>
</div>
<button
className="button is-primary"
type="button"
onClick={handleSaveMarkdownTemplateSetting}
>
Save Template Setting
</button>
</div>
) : (
<button
className="button is-primary"
type="button"
onClick={handleCreateTemplateSetting}
>
Create Template Setting
</button>
);
};
export default MarkdownTemplateSettingPanel;

View File

@@ -0,0 +1,433 @@
import React, { useEffect, useState } from "react";
import {
useWebhookSetting,
useCreateWebhookSetting,
useUpdateWebhookSetting,
useWebhooks,
} from "../../../utils/queries/webhook-queries";
import {
useCreateWebhook,
useUpdateWebhook,
useDeleteWebhook,
} from "../../../utils/queries/webhook-queries";
import {useUpdatePathSetting} from "../../../utils/queries/path-setting-queries";
const WebhookSettingPanel = ({pathSetting, onClose}) => {
const {data: setting} = useWebhookSetting(pathSetting?.webhook_setting_id || 0);
const {data: webhooks, isLoading: isWebhooksLoading} = useWebhooks();
const createWebhookSetting = useCreateWebhookSetting();
const updateWebhookSetting = useUpdateWebhookSetting();
const createWebhook = useCreateWebhook();
const updateWebhook = useUpdateWebhook();
const deleteWebhook = useDeleteWebhook();
const updatePathSetting = useUpdatePathSetting();
const [enabled, setEnabled] = useState(false);
const [isRecursive, setIsRecursive] = useState(false);
const [onEvents, setOnEvents] = useState(0);
const [selectedUrl, setSelectedUrl] = useState("");
const [headerList, setHeaderList] = useState([]);
const [additionalHeaders, setAdditionalHeaders] = useState({});
const [isOnMarkdownCreated, setIsOnMarkdownCreated] = useState(false);
const [isOnMarkdownUpdated, setIsOnMarkdownUpdated] = useState(false);
const [isOnMarkdownDeleted, setIsOnMarkdownDeleted] = useState(false);
const [isOnPathCreated, setIsOnPathCreated] = useState(false);
const [isOnPathUpdated, setIsOnPathUpdated] = useState(false);
const [isOnPathDeleted, setIsOnPathDeleted] = useState(false);
const assignFromOnEvents = (bits) => {
setIsOnMarkdownCreated(!!(bits & 1));
setIsOnMarkdownUpdated(!!(bits & 2));
setIsOnMarkdownDeleted(!!(bits & 4));
setIsOnPathCreated(!!(bits & 8));
setIsOnPathUpdated(!!(bits & 16));
setIsOnPathDeleted(!!(bits & 32));
};
const handleTriggerEventsUpdate = (eventType, isChecked) => {
setOnEvents((prev) => {
let nextVal = prev;
switch (eventType) {
case "MARKDOWN_CREATED":
nextVal = isChecked ? nextVal | 1 : nextVal & ~1;
setIsOnMarkdownCreated(isChecked);
break;
case "MARKDOWN_UPDATED":
nextVal = isChecked ? nextVal | 2 : nextVal & ~2;
setIsOnMarkdownUpdated(isChecked);
break;
case "MARKDOWN_DELETED":
nextVal = isChecked ? nextVal | 4 : nextVal & ~4;
setIsOnMarkdownDeleted(isChecked);
break;
case "PATH_CREATED":
nextVal = isChecked ? nextVal | 8 : nextVal & ~8;
setIsOnPathCreated(isChecked);
break;
case "PATH_UPDATED":
nextVal = isChecked ? nextVal | 16 : nextVal & ~16;
setIsOnPathUpdated(isChecked);
break;
case "PATH_DELETED":
nextVal = isChecked ? nextVal | 32 : nextVal & ~32;
setIsOnPathUpdated(isChecked);
break;
default:
break;
}
return nextVal;
});
};
const handleCreateWebhookSetting = () => {
createWebhookSetting.mutate({}, {
onSuccess: (data) => {
updatePathSetting.mutate({
id: pathSetting.id,
data: {
webhook_setting_id: data.id
}
});
}
});
};
useEffect(() => {
if (setting && webhooks) {
setEnabled(setting.enabled);
setIsRecursive(setting.recursive);
setOnEvents(setting.on_events);
assignFromOnEvents(setting.on_events);
try {
const headers = setting.additional_header
? JSON.parse(setting.additional_header)
: {};
setAdditionalHeaders(headers);
setHeaderList(
Object.entries(headers).map(([k, v]) => ({ key: k, value: v }))
);
} catch (err) {
setAdditionalHeaders({});
setHeaderList([]);
}
const found = webhooks.find((wh) => wh.id === setting.webhook_id);
setSelectedUrl(found ? found.hook_url : "");
} else {
setEnabled(false);
setIsRecursive(false);
setOnEvents(0);
assignFromOnEvents(0);
setAdditionalHeaders({});
setHeaderList([]);
setSelectedUrl("");
}
}, [setting, webhooks]);
const handleAddHeader = () => {
setHeaderList([...headerList, { key: "", value: "" }]);
};
const handleHeaderChange = (index, field, val) => {
const updated = [...headerList];
updated[index][field] = val;
setHeaderList(updated);
};
const handleApplyHeaders = () => {
const out = {};
headerList.forEach(({ key, value }) => {
if (key.trim()) out[key] = value;
});
setAdditionalHeaders(out);
};
const handleCreateWebhook = () => {
const newUrl = prompt("Enter the new webhook URL");
if (!newUrl) return;
createWebhook.mutate(newUrl, {
onSuccess: () => alert("Created new Webhook successfully"),
onError: () => alert("Failed to create new Webhook"),
});
};
const handleUpdateWebhook = () => {
if (!setting || !setting.webhook_id) {
alert("No webhook selected. Must pick from dropdown first.");
return;
}
const newUrl = prompt("Enter updated Webhook URL", selectedUrl);
if (!newUrl) return;
updateWebhook.mutate(
{ id: setting.webhook_id, data: { hook_url: newUrl } },
{
onSuccess: () => alert("Updated Webhook successfully"),
onError: () => alert("Failed to update Webhook"),
}
);
};
const handleDeleteWebhook = () => {
if (!setting || !setting.webhook_id) {
alert("No webhook selected to delete");
return;
}
if (!window.confirm("Are you sure?")) return;
deleteWebhook.mutate(setting.webhook_id, {
onSuccess: () => alert("Deleted Webhook successfully"),
onError: () => alert("Failed to delete Webhook"),
});
};
const handleSaveWebhookSetting = async () => {
const hook = webhooks.find((wh) => wh.hook_url === selectedUrl);
const payload = {
webhook_id: hook? hook.id : null,
recursive: isRecursive,
additional_header: JSON.stringify(additionalHeaders),
enabled,
on_events: onEvents,
};
if(!setting || !setting.id){
createWebhookSetting.mutate(payload, {
onSuccess: (res) => {
updatePathSetting.mutate({id: pathSetting.id, data: {webhook_setting_id: res.id}},{
onSuccess: () => alert("Webhook setting successfully created"),
onError: () => alert("Failed to save Webhook"),
})
},
onError: () => alert("Failed to save Webhook"),
});
} else {
updateWebhookSetting.mutate({id: setting.id, data: payload}, {
onSuccess: () => alert("Updated Webhook successfully"),
onError: () => alert("Failed to update Webhook"),
});
}
onClose();
};
return setting ? (
<div className="box" style={{ marginTop: "1rem" }}>
<h4 className="title is-5">Webhook Setting</h4>
<div className="field">
<label className="label">Select or Create a Webhook</label>
<div className="field has-addons">
<div className="control is-expanded">
{isWebhooksLoading ? (
<p>Loading...</p>
) : (
<div className="select is-fullwidth">
<select
value={selectedUrl}
onChange={(e) => setSelectedUrl(e.target.value)}
>
<option value="">(none)</option>
{webhooks.map((hook) => (
<option key={hook.id} value={hook.hook_url}>
{hook.hook_url}
</option>
))}
</select>
</div>
)}
</div>
<div className="control">
<button
type="button"
className="button is-primary"
onClick={handleCreateWebhook}
>
Add
</button>
</div>
</div>
{setting?.webhook_id && (
<div className="buttons" style={{ marginTop: "0.5rem" }}>
<button
type="button"
className="button is-info"
onClick={handleUpdateWebhook}
>
Update Webhook URL
</button>
<button
type="button"
className="button is-danger"
onClick={handleDeleteWebhook}
>
Delete Webhook
</button>
</div>
)}
</div>
<div className="field">
<label className="checkbox">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
&nbsp; Enabled
</label>
</div>
<div className="field">
<label className="label">On Events</label>
<div className="box">
<div className="columns">
<div className="column">
<label className="checkbox">
<input
type="checkbox"
checked={isOnMarkdownCreated}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked)
}
/>
&nbsp; Markdown Created
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnMarkdownUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked)
}
/>
&nbsp; Markdown Updated
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnMarkdownDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked)
}
/>
&nbsp; Markdown Deleted
</label>
</div>
<div className="column">
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathCreated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_CREATED", e.target.checked)
}
/>
&nbsp; Path Created
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
}
/>
&nbsp; Path Updated
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
}
/>
&nbsp; Path Deleted
</label>
</div>
</div>
</div>
</div>
<div className="field">
<label className="checkbox">
<input
type="checkbox"
checked={isRecursive}
onChange={(e) => setIsRecursive(e.target.checked)}
/>
&nbsp; Recursive
</label>
</div>
<div className="field">
<label className="label">Additional Headers</label>
<div className="box">
{headerList.map((h, idx) => (
<div className="columns" key={idx}>
<div className="column">
<input
type="text"
className="input"
placeholder="key"
value={h.key}
onChange={(e) => handleHeaderChange(idx, "key", e.target.value)}
/>
</div>
<div className="column">
<input
type="text"
className="input"
placeholder="value"
value={h.value}
onChange={(e) =>
handleHeaderChange(idx, "value", e.target.value)
}
/>
</div>
</div>
))}
<button
type="button"
className="button is-small is-info"
onClick={handleAddHeader}
>
+ Header
</button>
<button
type="button"
className="button is-small is-success"
onClick={handleApplyHeaders}
style={{ marginLeft: "0.5rem" }}
>
Apply
</button>
</div>
</div>
<button
type="button"
className="button is-primary"
onClick={handleSaveWebhookSetting}
>
Save Webhook Setting
</button>
</div>
) : (
<button
className="button is-primary"
type="button"
onClick={handleCreateWebhookSetting}
>
Create Webhook Setting
</button>
);
}
export default WebhookSettingPanel

View File

@@ -1,29 +0,0 @@
/*src/components/SideNavigation.css*/
.side-navigation {
width: 250px;
background-color: #f4f4f4;
padding: 1rem;
border-right: 1px solid #ddd;
}
.side-navigation h3 {
margin-top: 0;
}
.side-navigation ul {
list-style: none;
padding: 0;
}
.side-navigation ul li {
margin-bottom: 0.5rem;
}
.side-navigation ul li a {
text-decoration: none;
color: #333;
}
.side-navigation ul li a:hover {
text-decoration: underline;
}

View File

@@ -1,72 +0,0 @@
// src/components/SideNavigation.js
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import "./SideNavigation.css";
import {fetchWithCache} from "../utils/fetchWithCache";
const SideNavigation = () => {
const [markdowns, setMarkdowns] = useState([]);
const [tree, setTree] = useState(null);
useEffect(() => {
fetchWithCache("http://127.0.0.1:5000/api/markdown/")
.then((data) => {
setMarkdowns(data);
setTree(buildTree(data));
})
.catch((error) => console.log(error));
}, []);
function buildTree(markdowns) {
const root = {};
markdowns.forEach((markdown) => {
const segments = markdown.path.split("/").filter(Boolean);
let current = root;
segments.forEach((segment, index) => {
if (!current[segment]) {
current[segment] =
index === segments.length - 1 ? { markdown } : {};
}
current = current[segment];
});
});
return root;
}
function renderTree(node, basePath = "") {
return (
<ul>
{Object.entries(node).map(([key, value]) => {
if (value.markdown) {
return (
<li key={value.markdown.id}>
<Link to={`http://127.0.0.1:5000/markdown/${value.markdown.id}`}>
{value.markdown.title}
</Link>
</li>
);
}
return (
<li key={key}>
<span>{key}</span>
{renderTree(value, `${basePath}/${key}`)}
</li>
);
})}
</ul>
);
}
return (
<nav className="side-navigation">
<h3>Markdown Directory</h3>
{tree ? renderTree(tree) : <p>Loading...</p>}
</nav>
);
};
export default SideNavigation;

View File

@@ -1,13 +1,61 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import AuthProvider from "./AuthProvider";
import AuthProvider, {AuthContext} from "./AuthProvider";
import "bulma/css/bulma.min.css";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"
import ConfigProvider from "./ConfigProvider";
import ControlledReactQueryDevtools from "./components/Debug/ControlledReactQueryDevtools";
//ReactDOM.render(<App />, document.getElementById("root"));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
onError: (error) => {
if (error.message === "Unauthorized"){
const {logout} = queryClient
.getQueryCache()
.getAll()
.find(query => query.queryKey.includes("auth"))?.state?.context || {};
if (logout) {
logout();
}
}
}
},
mutations: {
retry: 1,
}
}
});
const EnhancedAuthProvider = ({children}) => {
const auth = React.useContext(AuthContext);
const logout = () => {
auth.logout();
};
React.useEffect(() => {
queryClient.setQueryDefaults(["auths"], {
context: {logout}
});
}, [logout]);
return children;
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<AuthProvider>
<App />
</AuthProvider>
);
<ConfigProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<EnhancedAuthProvider>
<App />
<ControlledReactQueryDevtools />
</EnhancedAuthProvider>
</AuthProvider>
</QueryClientProvider>
</ConfigProvider>
);

View File

@@ -1,8 +0,0 @@
export const oidcConfig = {
authority: "https://login.hangman-lab.top/realms/Hangman-Lab",
client_id: "labdev",
redirect_uri: "http://localhost:3000/callback",
post_logout_redirect_uri: "http://localhost:3000",
response_type: "code",
scope: "openid profile email",
};

8
src/store/index.js Normal file
View File

@@ -0,0 +1,8 @@
import { configureStore } from '@reduxjs/toolkit';
import navigationReducer from './navigationSlice';
export const store = configureStore({
reducer: {
navigation: navigationReducer,
},
});

View File

@@ -0,0 +1,24 @@
import { createSlice } from '@reduxjs/toolkit';
const navigationSlice = createSlice({
name: 'navigation',
initialState: {
selectedTab: "tree",
expandedNodes: {}
},
reducers: {
setSelectedTab: (state, action) => {
state.selectedTab = action.payload;
},
toggleNodeExpansion: (state, action) => {
const nodeId = action.payload;
state.expandedNodes[nodeId] = !state.expandedNodes[nodeId];
},
setExpandedNodes: (state, action) => {
state.expandedNodes = action.payload;
}
}
});
export const { setSelectedTab, toggleNodeExpansion, setExpandedNodes } = navigationSlice.actions;
export default navigationSlice.reducer;

View File

@@ -1,40 +0,0 @@
const ongoingRequests = new Map();
export async function fetchWithCache(url, cacheKey = url, cacheExpiry = 60) {
if (ongoingRequests.has(url)) {
return ongoingRequests.get(url);
}
const cachedData = localStorage.getItem(cacheKey);
const now = Date.now();
if (cachedData) {
const { data, timestamp } = JSON.parse(cachedData);
if (now - timestamp < cacheExpiry * 1000) {
console.log("Cache hit for:", url);
return data;
} else {
console.log("Cache expired for:", url);
}
}
try {
const fetchPromise = fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.then((data) => {
localStorage.setItem(cacheKey, JSON.stringify({ data, timestamp: now }));
ongoingRequests.delete(url);
return data;
});
ongoingRequests.set(url, fetchPromise);
return await fetchPromise;
} catch (error) {
ongoingRequests.delete(url);
throw error;
}
}

45
src/utils/pathUtils.js Normal file
View File

@@ -0,0 +1,45 @@
export const findMarkdownByPath = (tree, pathString) => {
if (!tree || !pathString) return null;
const pathSegments = pathString.split('/').filter(segment => segment.length > 0);
if (pathSegments.length === 0) {
const rootIndex = tree.children?.find(
child => child.type === 'markdown' && child.title === 'index'
);
return rootIndex || null;
}
let currentNode = tree;
for (let i = 0; i < pathSegments.length; i++) {
const segment = pathSegments[i];
const childPath = currentNode.children?.find(
child => child.type === 'path' && child.name === segment
);
if (!childPath) {
if (i === pathSegments.length - 1) {
const markdownNode = currentNode.children?.find(
child => child.type === 'markdown' && child.title === segment
);
return markdownNode || null;
}
return null;
}
currentNode = childPath;
}
const indexMarkdown = currentNode.children?.find(
child => child.type === 'markdown' && child.title === 'index'
);
return indexMarkdown || null;
};
export const getMarkdownIdByPath = (tree, pathString) => {
const markdownNode = findMarkdownByPath(tree, pathString);
return markdownNode?.id || null;
};

View File

@@ -0,0 +1,32 @@
import { useConfig } from "../../ConfigProvider";
import { useMutation } from "@tanstack/react-query";
import { fetch_ } from "../request-utils";
export const useCreateApiKey = () => {
const config = useConfig();
return useMutation({
mutationFn: async ({ name, roles }) => {
const response = await fetch_(`${config.BACKEND_HOST}/api/apikey/`, {
method: "POST",
body: JSON.stringify({ name, roles }),
});
console.log("response", response);
return response;
},
cacheTime: 0,
});
};
export const useRevokeApiKey = () => {
const config = useConfig();
return useMutation({
mutationFn: async (apiKey) => {
const response = await fetch_(`${config.BACKEND_HOST}/api/apikey/revoke`, {
method: "POST",
body: JSON.stringify({ apiKey }),
});
return response;
},
cacheTime: 0,
});
};

View File

@@ -0,0 +1,77 @@
import {useConfig} from "../../ConfigProvider";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import {fetch_} from "../request-utils";
export const useMarkdownPermissionSettings = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useQuery({
queryKey: ["markdown_permission_settings"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/`),
onSuccess: async (data) => {
if (data) {
for (const setting of data) {
await queryClient.invalidateQueries(["markdown_permission_setting", setting.id]);
}
}
}
}
);
};
export const useMarkdownPermissionSetting = (setting_id) => {
const config = useConfig();
return useQuery({
queryKey: ["markdown_permission_setting", setting_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/${setting_id}`),
enabled: !!setting_id,
}
);
};
export const useCreateMarkdownPermissionSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation(
{
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/`, {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: async (data) => {
await queryClient.invalidateQueries(["markdown_permission_setting", data.id]);
await queryClient.invalidateQueries(["markdown_permission_settings"]);
}
});
};
export const useUpdateMarkdownPermissionSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation(
({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),{
onSuccess: (res) => {
queryClient.invalidateQueries(["markdown_permission_setting", res.id]);
queryClient.invalidateQueries(["markdown_permission_settings"]);
}
}
);
};
export const useDeleteMarkdownPermissionSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation(
({id}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/${id}`, {
method: "DELETE",
}), {
onSuccess: (res, variables) => {
queryClient.invalidateQueries(["markdown_permission_setting", variables.id]);
queryClient.invalidateQueries(["markdown_permission_settings"]);
}
}
);
};

View File

@@ -0,0 +1,133 @@
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query';
import {fetch_} from "../request-utils";
import {useConfig} from "../../ConfigProvider";
export const useMarkdown = (id) => {
const config = useConfig();
return useQuery({
queryKey: ["markdown", id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/${id}`),
enabled: !!id,
});
};
export const useIndexMarkdown = (path_id) => {
const queryClient = useQueryClient();
const config = useConfig();
return useQuery({
queryKey: ["index_markdown", path_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/get_index/${path_id}`),
enabled: !!path_id,
onSuccess: (data) => {
if(data && data.id){
queryClient.setQueryData({queryKey: ["markdown", data.id]}, data);
}
}
});
};
export const useHomeMarkdown = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useQuery({
queryKey: ["home_markdown"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/get_home`),
onSuccess: (data) => {
if (data && data.id){
queryClient.setQueryData({queryKey: ["markdown", data.id]}, data);
}
}
});
};
export const useMarkdownsByPath = (pathId) => {
const config = useConfig();
return useQuery({
queryKey: ["markdownsByPath", pathId],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/by_path/${pathId}`),
enabled: !!pathId
});
};
export const useSaveMarkdown = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useMutation({
mutationFn: ({id, data}) => {
const url = id
? `${config.BACKEND_HOST}/api/markdown/${id}`
: `${config.BACKEND_HOST}/api/markdown/`;
const method = id ? "PATCH" : "POST";
return fetch_(url, {
method,
body: JSON.stringify(data),
});
},
onSuccess: async (res) => {
await queryClient.invalidateQueries({queryKey: ["markdownsByPath", res.path_id]});
await queryClient.invalidateQueries({queryKey: ["markdown", res.id]});
await queryClient.invalidateQueries({queryKey: ["tree"]});
console.log("invalidateQueries: ", res.id, typeof res.id);
},
});
};
export const useMoveMarkdown = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useMutation({
mutationFn: ({markdown, direction}) => {
const apiEndpoint = `${config.BACKEND_HOST}/api/markdown/move_${direction}/${markdown.id}`;
return fetch_(apiEndpoint, {method: "PATCH"});
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ["paths"]});
queryClient.invalidateQueries({queryKey: ["tree"]});
}
});
};
export const useDeleteMarkdown = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useMutation({
mutationFn: (markdownId) => {
return fetch_(`${config.BACKEND_HOST}/api/markdown/${markdownId}`, {
method: "DELETE"
});
},
onSuccess: (data, markdownId) => {
queryClient.invalidateQueries({queryKey: ["markdown", markdownId]});
queryClient.invalidateQueries({queryKey: ["tree"]});
queryClient.invalidateQueries({queryKey: ["markdownsByPath"]});
}
});
};
export const useSearchMarkdown = (keyword) => {
const config = useConfig();
return useQuery({
queryKey: ["markdownsByKeyword", keyword],
queryFn: () => fetch_(
`${config.BACKEND_HOST}/api/markdown/search/${encodeURIComponent(keyword)}`,
),
enabled: !!keyword,
});
};
export const useLinks = () => {
const config = useConfig();
return useQuery({
queryKey: ["links"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/links`)
});
}

View File

@@ -0,0 +1,70 @@
import {useConfig} from "../../ConfigProvider";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import {fetch_} from "../request-utils";
export const useMarkdownSettings = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useQuery({
queryKey: ["markdown_setting"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/`),
onSuccess: (data) => {
if(data){
for(const setting of data)
queryClient.setQueryData({queryKey: ["markdown_setting", setting.id]}, setting);
}
}
});
};
export const useMarkdownSetting = (setting_id) => {
const config = useConfig();
return useQuery({
queryKey: ["markdown_setting", setting_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/${setting_id}`, {}),
enabled: !!setting_id,
});
};
export const useCreateMarkdownSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/`, {
method: "POST",
body: JSON.stringify(data)
}),
onSuccess: (data) => {
queryClient.invalidateQueries({queryKey: ["markdown_setting", data.id]});
}
});
};
export const useUpdateMarkdownSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/${id}`, {
method: "PATCH",
body: JSON.stringify(data)
}),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({queryKey: ["markdown_setting", variables.id]});
}
});
};
export const useDeleteMarkdownSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/${id}`, {
method: "DELETE",
}),
onSuccess: (data) => {
queryClient.invalidateQueries({queryKey: ["markdown_setting", data.id]});
}
});
};

View File

@@ -0,0 +1,96 @@
import {useConfig} from "../../ConfigProvider";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import {fetch_} from "../request-utils";
import {template} from "@babel/core";
import {data} from "react-router-dom";
export const useMarkdownTemplate = (template_id) => {
const config = useConfig();
return useQuery({
queryKey: ["markdown_template", template_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/template/markdown/${template_id}`),
enabled: !!template_id,
});
};
export const useMarkdownTemplates = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useQuery({
queryKey: ["markdown_templates"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/template/markdown/`),
onSuccess: (data) => {
if(data){
for(const template of data){
queryClient.setQueryData({queryKey: ["markdown_template", template.id]}, template);
}
}
}
});
};
export const useUpdateMarkdownTemplate = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/template/markdown/${id}`, {
method: "PUT",
body: JSON.stringify(data),
}),
onSuccess: (data) => {
queryClient.invalidateQueries({queryKey: ["markdown_template", data.id]});
queryClient.invalidateQueries({queryKey: ["markdown_templates"]});
}
});
}
export const useCreateMarkdownTemplate = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/template/markdown/`, {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: (data) => {
queryClient.invalidateQueries({queryKey: ["markdown_template", data.id]});
queryClient.invalidateQueries({queryKey: ["markdown_templates"]});
}
});
}
export const useDeleteMarkdownTemplate = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/template/markdown/${id}`, {
method: "DELETE"
}),
onSuccess: (res, variables) => {
queryClient.invalidateQueries({queryKey: ["markdown_template", variables]});
queryClient.invalidateQueries({queryKey: ["markdown_templates"]});
}
});
}
export const useSaveMarkdownTemplate = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, data}) => {
const url = id
? `${config.BACKEND_HOST}/api/template/markdown/${id}`
: `${config.BACKEND_HOST}/api/template/markdown/`;
const method = id? "PUT": "POST";
return fetch_(url, {
method,
body: JSON.stringify(data),
});
},
onSuccess: (data) => {
queryClient.invalidateQueries({queryKey: ["markdown_template", data.id]});
queryClient.invalidateQueries({queryKey: ["markdown_templates"]});
}
});
}

View File

@@ -0,0 +1,71 @@
import {useConfig} from "../../ConfigProvider";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import {fetch_} from "../request-utils";
export const useMarkdownTemplateSettings = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useQuery({
queryKey: ["markdown_template_settings"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/`),
onSuccess: (data) => {
if(data){
for(const setting of data){
queryClient.setQueryData({queryKey: ["markdown_template_setting", setting.id]}, setting);
}
}
}
});
};
export const useMarkdownTemplateSetting = (setting_id) => {
const config = useConfig();
return useQuery({
queryKey: ["markdown_template_setting", setting_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/${setting_id}`),
enabled: !!setting_id,
});
};
export const useCreateMarkdownTemplateSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/`, {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: (data) => {
queryClient.invalidateQueries({queryKey: ["markdown_template_setting", data.id]});
}
});
};
export const useUpdateMarkdownTemplateSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
onSuccess: (res) => {
queryClient.invalidateQueries({queryKey: ["markdown_template_setting", res.id]});
queryClient.invalidateQueries({queryKey: ["markdown_template_settings"]});
}
});
};
export const useDeleteMarkdownTemplateSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/${id}`, {
method: "DELETE",
}),
onSuccess: (res, variables) => {
queryClient.invalidateQueries({queryKey: ["markdown_template_setting", variables.id]});
}
});
};

View File

@@ -0,0 +1,97 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { fetch_ } from "../request-utils";
import {useConfig} from "../../ConfigProvider";
export const usePaths = (parent_id) => {
const queryClient = useQueryClient();
const config = useConfig();
return useQuery({
queryKey: ["paths", parent_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/path/parent/${parent_id}`),
enabled: !!parent_id,
onSuccess: (data) => {
if(data) {
for (const pth of data)
{
queryClient.setQueryData({queryKey: ["path", pth.id]}, pth);
}
}
}
});
};
export const usePath = (id) => {
const config = useConfig();
return useQuery({
queryKey: ["path", id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/path/${id}`),
enabled: !!id
});
};
export const useCreatePath = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/path/`, {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: (res) => {
queryClient.invalidateQueries({queryKey: ["paths", res.parent_id]});
queryClient.invalidateQueries({queryKey: ["tree"]});
},
});
};
export const useUpdatePath = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
onSuccess: (res) => {
queryClient.invalidateQueries({queryKey: ["paths", res.parent_id]});
queryClient.invalidateQueries({queryKey: ["path", res.id]});
queryClient.invalidateQueries({queryKey: ["tree"]});
},
});
};
export const useDeletePath = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ["paths"]});
queryClient.invalidateQueries({queryKey: ["tree"]});
},
});
};
export const useMovePath = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useMutation({
mutationFn: ({path, direction}) => {
const apiEndpoint = `${config.BACKEND_HOST}/api/path/move_${direction}/${path.id}`;
return fetch_(apiEndpoint, {method: "PATCH"});
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ["paths"]});
queryClient.invalidateQueries({queryKey: ["tree"]});
}
});
};

View File

@@ -0,0 +1,73 @@
import {useConfig} from "../../ConfigProvider";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
import {fetch_} from "../request-utils";
export const usePathSettings = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useQuery({
queryKey: ["path_settings"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/path/`),
onSuccess: (data) => {
if(data){
for(const setting of data)
queryClient.setQueryData({queryKey: ["path_setting", setting.id]}, setting);
}
}
});
};
export const usePathSetting = (setting_id) => {
const config = useConfig();
return useQuery({
queryKey: ["path_setting", setting_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/path/${setting_id}`),
enabled: !!setting_id,
});
};
export const useCreatePathSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/path/`, {
method: "POST",
body: JSON.stringify(data)
}),
onSuccess: (data) => {
queryClient.invalidateQueries({queryKey: ["path_setting", data.id]});
queryClient.invalidateQueries({queryKey: ["path_settings"]});
}
});
};
export const useUpdatePathSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/path/${id}`, {
method: "PATCH",
body: JSON.stringify(data)
}),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({queryKey: ["path_setting", variables.id]});
queryClient.invalidateQueries({queryKey: ["path_settings"]});
}
});
};
export const useDeletePathSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/setting/path/${id}`, {
method: "DELETE",
}),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({queryKey: ["path_setting", variables.id]});
queryClient.invalidateQueries({queryKey: ["path_settings"]});
}
});
};

View File

@@ -0,0 +1,17 @@
import {useQuery, useMutation, useQueryClient} from "@tanstack/react-query";
import {fetch_} from "../request-utils";
import {useConfig} from "../../ConfigProvider";
export const useTree = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useQuery({
queryKey: ["tree"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/tree/`),
onSuccess: data => {
if(data)
queryClient.setQueryData({queryKey: ["tree"]}, data);
}
});
};

View File

@@ -0,0 +1,136 @@
import {fetch_ } from "../request-utils"
import {useConfig} from "../../ConfigProvider";
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
export const useWebhooks = () =>{
const queryClient = useQueryClient();
const config = useConfig();
return useQuery({
queryKey: ["webhooks"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/webhook/`),
onSuccess: (data) => {
if(data){
for(const webhook of data){
queryClient.setQueryData({queryKey: ["webhook", data.id]}, data);
}
}
}
});
};
export const useCreateWebhook = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/webhook/`, {
method: "POST",
body: JSON.stringify({
"hook_url": data
}),
}),
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ["webhooks"]});
}
});
};
export const useUpdateWebhook = () =>{
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/webhook/${id}`, {
method: "PATCH",
body: JSON.stringify(data)
}),
onSuccess: (res) => {
queryClient.invalidateQueries({queryKey: ["webhook", res.id]});
queryClient.invalidateQueries({queryKey: ["webhooks"]});
}
});
};
export const useDeleteWebhook = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/webhook/${id}`, {
method: "DELETE",
}),
onSuccess: (res, variables) => {
queryClient.invalidateQueries({queryKey: ["webhook", variables.id]});
queryClient.invalidateQueries({queryKey: ["webhooks"]});
}
});
}
export const useWebhookSettings = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useQuery({
queryKey: ["webhook_setting"],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/`),
onSuccess: (data) => {
if(data){
for(const setting of data){
queryClient.setQueryData({queryKey: ["webhook_setting", setting.id]}, setting);
}
}
}
});
};
export const useWebhookSetting = (setting_id) => {
const config = useConfig();
return useQuery({
queryKey: ["webhook_setting", setting_id],
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/${setting_id}`),
enabled: !!setting_id,
});
};
export const useCreateWebhookSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/`, {
method: "POST",
body: JSON.stringify(data)
}),
onSuccess: (res) => {
queryClient.invalidateQueries({queryKey: ["webhook_setting", res.id]});
queryClient.invalidateQueries({queryKey: ["webhook_setting"]});
}
});
};
export const useUpdateWebhookSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/${id}`, {
method: "PATCH",
body: JSON.stringify(data)
}),
onSuccess: (res, variables) => {
queryClient.invalidateQueries({queryKey: ["webhook_setting", variables.id]});
queryClient.invalidateQueries({queryKey: ["webhook_setting"]});
}
});
};
export const useDeleteWebhookSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/${id}`, {
method: "DELETE",
}),
onSuccess: (res, variables) => {
queryClient.invalidateQueries({queryKey: ["webhook_setting", variables.id]});
queryClient.invalidateQueries({queryKey: ["webhook_setting"]});
}
});
};

View File

@@ -0,0 +1,39 @@
export async function fetch_(url, init = {}) {
const token = localStorage.getItem("accessToken");
const headers = {
'Content-Type': 'application/json',
...(init.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const response = await fetch(url, { ...init, headers });
if (response.status === 304) {
return Promise.reject(new Error("Not Modified"));
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const error = new Error(response.statusText);
error.data = errorData;
throw error;
}
if (response.status === 204) {
return null;
}
if (response.status === 203) {
const data = await response.json();
return {
id: null,
content: data.msg || "Non-authoritative information received",
title: "Message",
isMessage: true,
...data
};
}
return response.json();
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import AuthProvider, { AuthContext } from "../src/AuthProvider";
import "@testing-library/jest-dom";
describe("AuthProvider", () => {
it("should provide default user and roles as null and empty array", () => {
render(
<AuthProvider>
<AuthContext.Consumer>
{({ user, roles }) => (
<>
<div data-testid="user">{user ? user.name : "null"}</div>
<div data-testid="roles">{roles.length}</div>
</>
)}
</AuthContext.Consumer>
</AuthProvider>
);
expect(screen.getByTestId("user")).toHaveTextContent("null");
expect(screen.getByTestId("roles")).toHaveTextContent("0");
});
});

View File

@@ -0,0 +1,45 @@
import React from "react";
import { render, screen, act } from "@testing-library/react";
import ConfigProvider from "../src/ConfigProvider";
global.fetch = jest.fn(() =>
new Promise((resolve) =>
setTimeout(() => {
resolve({
ok: true,
json: () =>
Promise.resolve({
BACKEND_HOST: "http://localhost:5000",
FRONTEND_HOST: "http://localhost:3000",
}),
});
}, 100)
)
);
describe("ConfigProvider", () => {
it("should display loading initially", async () => {
await act(async () => {
render(
<ConfigProvider>
<div>Loaded</div>
</ConfigProvider>
);
});
expect(screen.getByText(/Loading configuration/i)).toBeInTheDocument();
});
it("should display children after loading", async () => {
await act(async () => {
render(
<ConfigProvider>
<div>Loaded</div>
</ConfigProvider>
);
});
const loadedText = await screen.findByText("Loaded");
expect(loadedText).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
import React from "react";
import PermissionGuard from "../src/components/PermissionGuard";
import { AuthContext } from "../src/AuthProvider";
import { render, screen } from "@testing-library/react";
describe("PermissionGuard", () => {
it("should render children for users with required roles", () => {
const roles = ["admin"];
render(
<AuthContext.Provider value={{ roles }}>
<PermissionGuard rolesRequired={["admin"]}>
<div data-testid="protected-content">Protected Content</div>
</PermissionGuard>
</AuthContext.Provider>
);
expect(screen.getByTestId("protected-content")).toBeInTheDocument();
});
it("should not render children for users without required roles", () => {
const roles = ["user"];
render(
<AuthContext.Provider value={{ roles }}>
<PermissionGuard rolesRequired={["admin"]}>
<div data-testid="protected-content">Protected Content</div>
</PermissionGuard>
</AuthContext.Provider>
);
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
});
});

View File

@@ -1,12 +1,12 @@
//webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, './dist'),
filename: 'bundle.js',
publicPath: '/',
clean: true,
},
module: {
@@ -27,7 +27,12 @@ module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html",
inject: true
}),
new webpack.ProvidePlugin({
process: 'process/browser.js'
})
],
devServer: {
static: path.join(__dirname, 'public'),
@@ -35,5 +40,17 @@ module.exports = {
open: true,
hot: true,
historyApiFallback: true,
}
};
},
resolve: {
alias: {
'process/browser': require.resolve('process/browser.js')
},
fallback: {
path: require.resolve('path-browserify'),
fs: false,
assert: require.resolve("assert/"),
process: require.resolve("process/browser.js"),
}
},
devtool: 'source-map',
};