Compare commits

..

48 Commits

Author SHA1 Message Date
ba08bba7de fix: valid config.json + content-hashed bundle (cache-bust)
- BuildConfig.sh: ${DEBUG:false} -> ${DEBUG:-false} and normalize to
  true/false. The old syntax produced empty -> invalid config.json
  ("DEBUG": }) when DEBUG was unset, breaking the whole frontend.
- webpack: output [name].[contenthash].js so index.html references a
  unique bundle URL each build; eliminates stale CDN/browser bundle
  after deploys (no manual cache purge needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:16 +01:00
h z
e91bea280b Merge pull request 'fix/security-hardening' (#1) from fix/security-hardening into master
Reviewed-on: #1
2026-05-16 16:29:51 +00:00
952387d50f feat: dark-tech UI redesign + markdown patch cards
Redesign the frontend with a dark-tech theme: add Tailwind + PostCSS,
design tokens, and shadcn-style primitives (Button/Card/Input/Dialog/
DropdownMenu/Tabs/ScrollArea/etc.); restyle the app shell, navigation,
sidebar tree, content view, markdown rendering, editors, modals and
settings panels. Behavior/props unchanged; Font Awesome replaced with
lucide-react.

Add the patch cards feature UI: patch-queries hooks and a PatchCards
component rendered below the markdown body, with an Add Patch button
and create/edit dialog.

Fix tree expandability: folders with an index page now expand on name
click (and navigate), and the chevron+folder icon is one larger toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:28:13 +01:00
045c7c51d6 Security hardening: prevent stored XSS and render crashes
- MarkdownView: add rehype-sanitize between rehype-raw and rehype-katex
  to strip scripts/event-handlers/javascript: URLs from user-authored
  markdown (was stored XSS, also affected the public /pg/* route);
  keep className on code/span/div so KaTeX and syntax highlighting
  still work. Add rehype-sanitize ^6.0.0 to deps and lockfile.
- MarkdownContent / StandaloneMarkdownPage: parse markdown content via
  parseMarkdownContent() instead of an unguarded JSON.parse, so a single
  corrupt/legacy record no longer white-screens the whole page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:12:56 +01:00
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
100 changed files with 225286 additions and 1001 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,36 +1,37 @@
#!/bin/bash
rm -f /app/config.js;
if [ -z "$BACKEND_HOST" ]; then
BACKEND_HOST="http://localhost:5000"
fi
if [ -z "$FRONTEND_HOST" ]; then
FRONTEND_HOST="http://localhost:3000"
fi
if [ -z "$KC_CLIENT_ID" ]; then
KC_CLIENT_ID="labdev"
fi
if [ -z "$KC_HOST" ]; then
KC_HOST="https://login.hangman-lab.top"
fi
if [ -z "$KC_REALM" ]; then
KC_REALM="Hangman-Lab"
fi
#!/bin/sh
mkdir -p /app/src
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}"
# Note: ${DEBUG:-false} (correct default syntax). The old ${DEBUG:false}
# produced an empty value when DEBUG was unset -> invalid config.json.
DEBUG="${DEBUG:-false}"
# DEBUG is emitted unquoted as a JSON boolean — guarantee it is exactly
# true/false so config.json can never be invalid JSON.
case "$DEBUG" in true|false) ;; *) DEBUG=false ;; esac
rm -f /usr/share/nginx/html/config.js
echo "
const config = {
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\",
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"
},
};
export default config;
" > /app/src/config.js;
"DEBUG": ${DEBUG}
}
EOL
exec "$@"

View File

@@ -1,15 +1,27 @@
FROM node:18 as build-stage
FROM node:20-alpine AS build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
COPY BuildConfig.sh /app/BuildConfig.sh
RUN chmod +x /app/BuildConfig.sh
RUN npm run build
EXPOSE 3000
CMD ["/bin/bash", "-c", "/app/BuildConfig.sh && 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;
}

6606
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,28 +12,66 @@
"author": "",
"license": "ISC",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.6",
"@reduxjs/toolkit": "^2.7.0",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
"assert": "^2.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.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-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-math": "^6.0.0"
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"tailwind-merge": "^2.6.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",
"autoprefixer": "^10.4.20",
"axios-mock-adapter": "^2.1.0",
"babel-jest": "^29.7.0",
"babel-loader": "^9.2.1",
"css-loader": "^7.1.2",
"postcss": "^8.4.49",
"postcss-loader": "^8.1.1",
"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",
"tailwindcss": "^3.4.17",
"webpack": "^5.96.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.1.0"

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

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

@@ -1,11 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<title>Hangman Lab</title>
<link rel="icon" type="image/png" href="./icons/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</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

@@ -1,16 +1,4 @@
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
/* Layout now lives in App.js via Tailwind; keep body from double-scrolling. */
:root {
overflow: hidden;
}
.content-container {
display: flex;
flex: 1;
}
.main-content {
flex: 1;
padding: 1rem;
overflow-y: auto;
}

View File

@@ -1,35 +1,59 @@
import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
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/MarkdownContent";
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 config from "./config";
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";
import { TooltipProvider } from "./components/ui/misc";
const App = () => {
console.log(config)
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>
<Route path="/markdown/create" element={<MarkdownEditor />}></Route>
<Route path="/markdown/edit/:id" element={<MarkdownEditor />}></Route>
</Routes>
</main>
</div>
</div>
</Router>
<Provider store={store}>
<TooltipProvider delayDuration={200}>
<Router>
<Routes>
<Route path="/pg/*" element={<StandaloneMarkdownPage />} />
<Route path="*" element={
<div className="relative z-10 flex h-screen flex-col">
<MainNavigation />
<div className="flex flex-1 overflow-hidden">
<SideNavigation />
<main className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-5xl px-6 py-8 pb-28">
<Routes>
<Route
path="/"
element={<Navigate to="/markdown/1" />}
/>
<Route path="/markdown/:strId" element={<MarkdownContent />} />
<Route path="/callback" element={<Callback />} />
<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>
</div>
</main>
</div>
<Footer />
</div>
} />
</Routes>
</Router>
</TooltipProvider>
</Provider>
);
};
export default App;
export default App;

View File

@@ -1,7 +1,6 @@
import React, {createContext, useEffect, useMemo, useState} from "react";
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import { UserManager } from "oidc-client-ts";
import config from "./config";
import { ConfigContext } from "./ConfigProvider";
export const AuthContext = createContext({
user: null,
@@ -11,12 +10,20 @@ export const AuthContext = createContext({
});
const AuthProvider = ({ children }) => {
const { config, isLoading, error } = useContext(ConfigContext);
const [user, setUser] = useState(null);
const [roles, setRoles] = useState([]);
const userManager =
useMemo(() => new UserManager(config.OIDC_CONFIG), []);
const userManager = useMemo(() => {
if (config && config.OIDC_CONFIG) {
return new UserManager(config.OIDC_CONFIG);
}
return null;
}, [config]);
useEffect(() => {
if (isLoading || error || !userManager) return;
userManager.getUser()
.then((user) => {
if (user && !user.expired) {
@@ -30,27 +37,58 @@ const AuthProvider = ({ children }) => {
.then((newUser) => {
setUser(newUser);
localStorage.setItem("accessToken", newUser.access_token);
const clientRoles =
newUser?.profile?.resource_access?.[config.KC_CLIENT_ID]?.roles || [];
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();
});
}, [userManager]);
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 = () => {
userManager
.signinRedirect()
.catch(
(err) => {
console.log(config);
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, roles, login, logout }}>
@@ -59,4 +97,4 @@ const AuthProvider = ({ children }) => {
);
};
export default AuthProvider;
export default AuthProvider;

View File

@@ -1,18 +0,0 @@
import React, { useEffect } from "react";
import { UserManager } from "oidc-client-ts";
import config from "./config";
const Callback = () => {
useEffect(() => {
const userManager = new UserManager(config.OIDC_CONFIG);
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);
}

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

@@ -0,0 +1,64 @@
import React from "react";
import { ChevronUp, Mail, GitBranch, Linkedin } from "lucide-react";
import { cn } from "../lib/utils";
const Footer = () => {
const [open, setOpen] = React.useState(false);
return (
<footer className="glass fixed bottom-0 left-0 right-0 z-20 border-t border-border">
<div
className={cn(
"overflow-hidden transition-all duration-300",
open ? "max-h-20" : "max-h-0"
)}
>
<div className="flex items-center justify-center gap-6 py-3 text-xs">
<a
href="https://www.linkedin.com/in/zhhrozhh/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
>
<Linkedin className="h-3.5 w-3.5" /> LinkedIn
</a>
<a
href="https://git.hangman-lab.top/hzhang/HangmanLab"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
>
<GitBranch className="h-3.5 w-3.5" /> Git
</a>
<a
href="mailto:hzhang@hangman-lab.top"
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
>
<Mail className="h-3.5 w-3.5" /> Email
</a>
</div>
</div>
<div className="flex h-9 items-center justify-between px-4">
<p className="font-mono text-[11px] text-muted-foreground">
© {new Date().getFullYear()}{" "}
<span className="text-foreground/70">Hangman Lab</span>
</p>
<button
type="button"
onClick={() => setOpen((p) => !p)}
className="inline-flex items-center gap-1 rounded px-2 py-0.5 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronUp
className={cn(
"h-3.5 w-3.5 transition-transform",
open && "rotate-180"
)}
/>
{open ? "less" : "more"}
</button>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,24 @@
import React, {useContext, useEffect} from "react";
import { UserManager } from "oidc-client-ts";
import {ConfigContext} from "../../ConfigProvider";
import { Spinner } from "../ui/misc";
const Callback = () => {
const config = useContext(ConfigContext).config;
useEffect(() => {
const userManager = new UserManager(config.OIDC_CONFIG);
userManager.signinRedirectCallback()
.then(() => {
window.location.href = "/";
});
}, []);
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Logging in" />
</div>
);
};
export default Callback;

View File

@@ -0,0 +1,29 @@
import React, { useEffect, useContext } from "react";
import { UserManager } from "oidc-client-ts";
import {ConfigContext} from "../../ConfigProvider";
import { Spinner } from "../ui/misc";
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 className="flex min-h-screen items-center justify-center">
<Spinner label="Processing" />
</div>
);
};
export default PopupCallback;

View File

@@ -0,0 +1,26 @@
import React, { useEffect, useContext } from "react";
import { UserManager } from "oidc-client-ts";
import { ConfigContext } from "../../ConfigProvider";
import { Spinner } from "../ui/misc";
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 className="flex min-h-screen items-center justify-center">
<Spinner label="Renewing session" />
</div>
);
};
export default SilentCallback;

View File

@@ -1,37 +0,0 @@
//src/components/MarkdownContent.js
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import {fetch_} from "../utils/requestUtils";
const MarkdownContent = () => {
const { id } = useParams();
const [content, setContent] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
fetch_(`/api/markdown/${id}`, {}, {
use_cache: true,
use_token: false
})
.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,57 @@
import React, { useState } from "react";
import { Plus, X } from "lucide-react";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
const EnumsEditor = ({ enums, onChange }) => {
const [_enums, setEnums] = useState(enums || []);
return (
<div className="space-y-3 rounded-md border border-border bg-background/40 p-4">
<ul className="space-y-2">
{_enums.map((item, index) => (
<li key={index} className="flex items-center gap-2">
<Input
className="h-8 text-xs"
type="text"
value={item}
onChange={(e) => {
const updated = [..._enums];
updated[index] = e.target.value;
setEnums(updated);
onChange(updated);
}}
/>
<Button
type="button"
variant="destructive"
size="icon-sm"
onClick={() => {
const updated = [..._enums];
updated.splice(index, 1);
setEnums(updated);
onChange(updated);
}}
>
<X className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const updated = [..._enums, ""];
setEnums(updated);
onChange(updated);
}}
>
<Plus className="h-4 w-4" /> Add Enum
</Button>
</div>
);
};
export default EnumsEditor;

View File

@@ -0,0 +1,18 @@
import React, {useEffect, useState} from 'react';
import { Textarea } from '../ui/input';
const LayoutEditor = ({layout, onChange}) => {
const [_layout, setLayout] = useState(layout || "");
useEffect(() => {setLayout(layout)}, [layout]);
return (
<Textarea
className="h-[60vh] font-mono text-sm"
value={_layout}
onChange={(e) => {
setLayout(e.target.value);
onChange(e.target.value);
}}
/>
);
};
export default LayoutEditor;

View File

@@ -0,0 +1,107 @@
import React, {useContext, useEffect, useState} from "react";
import { AuthContext } from "../../AuthProvider";
import { useNavigate, useParams } from "react-router-dom";
import { Save } from "lucide-react";
import { useMarkdownTemplate, useSaveMarkdownTemplate } from "../../utils/queries/markdown-template-queries";
import LayoutEditor from "./LayoutEditor";
import ParametersManager from "./ParametersManager";
import { Input, Label } from "../ui/input";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
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 (
<div className="flex justify-center py-20">
<Spinner label="Loading template" />
</div>
);
}
if (!roles.includes("admin") || roles.includes("creator"))
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
Permission Denied
</div>
);
const handleSave = () => {
saveMarkdownTemplate.mutate(
{ id, data: { title, parameters, layout } },
{
onSuccess: () => {
navigate("/");
},
onError: () => {
alert("Error saving template.");
}
}
);
};
return (
<section className="mx-auto max-w-6xl px-6 py-8">
<h2 className="mb-6 font-mono text-2xl font-bold tracking-tight text-foreground">
Markdown Template Editor
</h2>
<div className="mb-6 space-y-2">
<Label htmlFor="template-title">Title</Label>
<Input
id="template-title"
type="text"
placeholder="Enter template title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div className="space-y-3">
<h3 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Layout
</h3>
<div className="rounded-lg border border-border bg-card p-4">
<LayoutEditor
layout={layout}
parameters={parameters}
onChange={(newLayout) => setLayout(newLayout)}
/>
</div>
</div>
<div className="space-y-3">
<h3 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Parameters
</h3>
<ParametersManager
parameters={parameters}
onChange={(newParameters) => setParameters(newParameters)}
/>
</div>
</div>
<div className="mt-6">
<Button onClick={handleSave}>
<Save className="h-4 w-4" /> Save Template
</Button>
</div>
</section>
);
};
export default MarkdownTemplateEditor;

View File

@@ -0,0 +1,121 @@
import React, {useEffect, useState} from "react";
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import TypeEditor from "./TypeEditor";
import { Input, Label } from "../ui/input";
import { Button } from "../ui/button";
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="space-y-4 rounded-lg border border-border bg-card p-5">
<Button type="button" onClick={handleAdd}>
<Plus className="h-4 w-4" /> Add Parameter
</Button>
<div className="max-h-[50vh] space-y-3 overflow-y-auto">
{_parameters.map((param, index) => (
<div key={index} className="space-y-3 rounded-md border border-border bg-surface/40 p-4">
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<Label>Name</Label>
<Input
type="text"
value={param.name}
onChange={(e) => handleNameChange(index, e.target.value)}
placeholder="Parameter name"
/>
</div>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => handleDelete(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Type</Label>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => toggleExpand(index)}
>
{expandedStates[index] ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
{expandedStates[index] && (
<TypeEditor
type={param.type}
onChange={(newType) => handleTypeChange(index, newType)}
/>
)}
</div>
</div>
))}
</div>
</div>
);
};
export default ParametersManager;

View File

@@ -0,0 +1,64 @@
import React, { useEffect, useState } from "react";
import { useMarkdownTemplates } from "../../utils/queries/markdown-template-queries";
import { Label } from "../ui/input";
import { Spinner } from "../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
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 <Spinner label="Loading templates" />;
}
return (
<div className="space-y-2">
<Label htmlFor="template-selector">Select Template</Label>
<select
id="template-selector"
className={SELECT_CLASS}
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>
);
};
export default TemplateSelector;

View File

@@ -0,0 +1,109 @@
import React from 'react';
import EnumsEditor from './EnumsEditor';
import TemplateSelector from './TemplateSelector';
import { Label, Textarea } from '../ui/input';
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
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="space-y-2">
<Label>Enums</Label>
<EnumsEditor
enums={_type.definition.enums}
onChange={(newEnums) => {
updateType({
..._type,
definition: { ..._type.definition, enums: newEnums },
});
}}
/>
</div>
);
case 'list':
return (
<div className="space-y-4 rounded-md border border-border bg-background/40 p-4">
<div className="space-y-2">
<Label>Extend Type</Label>
<TypeEditor
type={_type.extend_type}
onChange={(extendType) => {
updateType({ ..._type, extend_type: extendType });
}}
/>
</div>
<div className="space-y-2">
<Label>Iter Layout</Label>
<Textarea
className="font-mono text-sm"
value={_type.definition.iter_layout || ''}
onChange={(e) => {
updateType({
..._type,
definition: {
..._type.definition,
iter_layout: e.target.value,
},
});
}}
/>
</div>
</div>
);
case 'template':
return (
<TemplateSelector
template={_type.definition.template}
onChange={(newTemplate) => {
updateType({
..._type,
definition: {
..._type.definition,
template: newTemplate,
},
});
}}
/>
);
default:
return null;
}
};
return (
<div className="space-y-4 rounded-md border border-border bg-surface/40 p-4">
<select
className={SELECT_CLASS}
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>
{renderExtraFields()}
</div>
);
};
export default TypeEditor;

View File

@@ -0,0 +1 @@
/* Styling now lives in the components via Tailwind + MarkdownView.css. */

View File

@@ -0,0 +1,99 @@
import React, { useEffect, useState } from "react";
import {Link, useParams} from "react-router-dom";
import "katex/dist/katex.min.css";
import "./MarkdownContent.css";
import { Settings2, Pencil } from "lucide-react";
import MarkdownView from "./MarkdownView";
import PatchCards from "./PatchCards";
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";
import { parseMarkdownContent } from "../../utils/safe-json";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
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 className="flex justify-center py-20">
<Spinner label="Loading content" />
</div>
);
}
if (error) {
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
Error: {error.message || "Failed to load content"}
</div>
);
}
if (markdown.isMessage) {
return (
<div className="rounded-lg border border-primary/30 bg-primary/10 px-5 py-4">
<h4 className="mb-1 font-mono text-base font-semibold text-primary">
{markdown.title}
</h4>
<p className="text-sm text-foreground/80">{markdown.content}</p>
</div>
);
}
return (
<article>
<div className="mb-6 flex items-start justify-between gap-4 border-b border-border pb-4">
<h1 className="font-mono text-3xl font-bold tracking-tight text-foreground">
{markdown.title === "index" ? indexTitle : markdown.title}
</h1>
<PermissionGuard rolesRequired={['admin']}>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSettingModalOpen(true)}
>
<Settings2 className="h-4 w-4" /> Settings
</Button>
<Button asChild size="sm">
<Link to={`/markdown/edit/${id}`}>
<Pencil className="h-4 w-4" /> Edit
</Link>
</Button>
</div>
</PermissionGuard>
</div>
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/>
<PatchCards markdownId={id} />
<MarkdownSettingModal
isOpen={isSettingModalOpen}
markdown={markdown}
onClose={() => setSettingModalOpen(false)}
/>
</article>
);
};
export default MarkdownContent;

View File

@@ -1,104 +1,34 @@
.markdown-editor-container {
max-width: 800px;
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-preview {
padding: 15px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #ffffff;
white-space: pre-wrap;
word-wrap: break-word;
overflow-x: auto;
}
.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;
}
/* Layout/form styling moved to Tailwind / dark-tech design system in
MarkdownEditor.js. Only KaTeX sizing and code/pre theming for rendered
markdown remain here. */
.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;
font-family: ui-monospace, "JetBrains Mono", "Courier New", Courier, monospace;
background-color: hsl(var(--muted));
color: hsl(var(--foreground));
padding: 2px 6px;
border-radius: 4px;
}
pre {
background-color: #2d2d2d;
color: #f8f8f2;
padding: 10px;
background-color: hsl(var(--surface));
color: hsl(var(--foreground));
padding: 12px;
border: 1px solid hsl(var(--border));
border-radius: 6px;
overflow-x: auto;
}
}
.raw-editor {
white-space: pre;
tab-size: 2;
}

View File

@@ -1,158 +1,337 @@
import React, { useContext, useEffect, useState } from "react";
import { AuthContext } from "../../AuthProvider";
import { useNavigate, useParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
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 "./MarkdownEditor.css";
import config from "../../config";
import { fetch_ } from "../../utils/requestUtils";
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";
import { Save, Code, LayoutTemplate } from "lucide-react";
import { Input, Textarea, Label } from "../ui/input";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
const MarkdownEditor = () => {
const { roles } = useContext(AuthContext);
const navigate = useNavigate();
const { id } = useParams();
const { strId } = useParams();
const id = Number(strId);
const [title, setTitle] = useState("");
const [content, setContent] = 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 (id) {
fetch_(`/api/markdown/${id}`, {}, {
use_cache: true,
use_token: false
})
.then((data) => {
setTitle(data.title);
setContent(data.content);
setPathId(data.path_id);
})
.catch((err) => {
console.error("Failed to load markdown", err);
});
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("/");
}
}
}, [id]);
}, [markdown, navigate]);
useEffect(() => {
if (template) {
setSelectedTemplate(template);
}
}, [template]);
const handleSave = () => {
const url = id ? `${config.BACKEND_HOST}/api/markdown/${id}` : `${config.BACKEND_HOST}/api/markdown/`;
const method = id ? "PUT" : "POST";
fetch_(url, {
method,
body: JSON.stringify({ title, content, path_id: pathId }),
}, {
use_cache: false,
use_token: true,
}).then((data) => {
if(data.error)
throw new Error(data.error.message);
navigate("/");
}).catch((err) => {
console.error("Failed to load markdown", err);
});
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 (!hasPermission)
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
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 (
<div className="flex justify-center py-20">
<Spinner label="Loading editor" />
</div>
);
}
if(error)
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
{error.message || "Failed to load markdown"}
</div>
);
return (
<div className="container mt-5 markdown-editor-container">
<h2 className="title is-4">{id ? "Edit Markdown" : "Create Markdown"}</h2>
<div className="columns">
{/* Editor Column */}
<div className="column is-half">
<form>
{/* Title Field */}
<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 className="markdown-editor-container mx-auto max-w-[90vw] px-6 py-8">
<h2 className="mb-6 font-mono text-2xl font-bold tracking-tight text-foreground">
{id ? "Edit Markdown" : "Create Markdown"}
</h2>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div>
<form className="space-y-5">
<div className="space-y-2">
<Label htmlFor="md-title">Title</Label>
<Input
id="md-title"
type="text"
placeholder="Enter title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
{/* PathManager Field */}
<div className="field">
<label className="label">Path</label>
<div className="space-y-2">
<Label htmlFor="md-shortcut">Shortcut</Label>
<Input
id="md-shortcut"
type="text"
placeholder="Enter shortcut"
value={shortcut}
onChange={(e) => setShortcut(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Path</Label>
<PathManager
currentPathId={pathId}
onPathChange={setPathId}
/>
</div>
{/* Content Field */}
<div className="field">
<label className="label">Content</label>
<div className="control">
<textarea
style={{ height: "70vh" }}
className="textarea"
placeholder="Enter Markdown content"
value={content}
onChange={(e) => setContent(e.target.value)}
></textarea>
</div>
<div>
<TemplateSelector
template={selectedTemplate || template}
onChange={handleTemplateChange}
/>
</div>
{/* Save Button */}
<div className="field">
<div className="control">
<button
className="button is-primary"
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Content</Label>
<Button
type="button"
onClick={handleSave}
variant="outline"
size="sm"
onClick={toggleEditMode}
>
Save
</button>
{isRawMode ? (
<>
<LayoutTemplate className="h-4 w-4" /> Switch to Template Editor
</>
) : (
<>
<Code className="h-4 w-4" /> Switch to Raw Editor
</>
)}
</Button>
</div>
{isRawMode ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Edit the JSON directly. Make sure it's valid JSON before saving.
</p>
<Textarea
className={`raw-editor h-[70vh] font-mono text-sm ${jsonError ? "border-destructive focus-visible:border-destructive" : ""}`}
value={rawContent}
onChange={handleRawContentChange}
placeholder="Enter JSON content here"
/>
{jsonError && (
<p className="font-mono text-xs text-destructive">{jsonError}</p>
)}
</div>
) : (
<TemplatedEditor
style={{height: "40vh"}}
content={content}
template={!markdown?.id ? selectedTemplate : template}
onContentChanged={(k, v) => setContent(
prev => ({...prev, [k]: v})
)}
/>
)}
</div>
<div>
<Button
type="button"
onClick={handleSave}
disabled={saveMarkdown.isLoading}
>
<Save className="h-4 w-4" />
{saveMarkdown.isLoading ? "Saving..." : "Save"}
</Button>
</div>
</form>
</div>
{/* Preview Column */}
<div className="column is-half">
<h3 className="subtitle is-5">Preview</h3>
<div className="content markdown-preview" style={{ height: "70vh" }}>
<ReactMarkdown
children={content}
remarkPlugins={[remarkMath]}
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>
<div>
<h3 className="mb-3 font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Preview
</h3>
<MarkdownView
content={content}
template={!markdown?.id ? selectedTemplate : template}
height='70vh'
/>
</div>
</div>
</div>
);
};
export default MarkdownEditor;
export default MarkdownEditor;

View File

@@ -0,0 +1,173 @@
/* Dark-tech prose theme for rendered markdown. */
.markdown-preview {
color: hsl(var(--foreground) / 0.92);
line-height: 1.75;
font-size: 0.975rem;
white-space: normal;
word-wrap: break-word;
overflow-x: auto;
}
.markdown-preview > *:first-child {
margin-top: 0;
}
.markdown-preview p {
margin: 0 0 1rem;
}
.markdown-preview a {
color: hsl(var(--primary));
text-decoration: none;
border-bottom: 1px solid hsl(var(--primary) / 0.3);
transition: border-color 0.15s, color 0.15s;
}
.markdown-preview a:hover {
color: hsl(var(--primary));
border-bottom-color: hsl(var(--primary));
text-shadow: 0 0 10px hsl(var(--primary) / 0.5);
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3,
.markdown-preview h4,
.markdown-preview h5,
.markdown-preview h6 {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-weight: 600;
color: hsl(var(--foreground));
line-height: 1.3;
margin: 2rem 0 0.85rem;
scroll-margin-top: 5rem;
}
.markdown-preview h1 {
font-size: 1.9rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid hsl(var(--border));
}
.markdown-preview h1::before,
.markdown-preview h2::before {
content: "# ";
color: hsl(var(--primary) / 0.6);
}
.markdown-preview h2 {
font-size: 1.5rem;
}
.markdown-preview h3 {
font-size: 1.25rem;
color: hsl(var(--foreground) / 0.9);
}
.markdown-preview h4 {
font-size: 1.05rem;
}
.markdown-preview h5,
.markdown-preview h6 {
font-size: 0.95rem;
color: hsl(var(--muted-foreground));
}
.markdown-preview ul,
.markdown-preview ol {
padding-left: 1.5rem;
margin: 0 0 1rem;
}
.markdown-preview ul {
list-style-type: disc;
}
.markdown-preview ol {
list-style-type: decimal;
}
.markdown-preview li {
margin-bottom: 0.4rem;
}
.markdown-preview li::marker {
color: hsl(var(--primary) / 0.7);
}
.markdown-preview blockquote {
margin: 0 0 1rem;
padding: 0.5rem 1rem;
border-left: 3px solid hsl(var(--secondary));
background: hsl(var(--secondary) / 0.08);
color: hsl(var(--foreground) / 0.8);
border-radius: 0 6px 6px 0;
}
.markdown-preview hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 2rem 0;
}
.markdown-preview img {
max-width: 100%;
border-radius: 8px;
border: 1px solid hsl(var(--border));
}
/* Inline code */
.markdown-preview :not(pre) > code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85em;
background-color: hsl(var(--muted));
color: hsl(var(--primary));
padding: 0.15em 0.4em;
border-radius: 4px;
border: 1px solid hsl(var(--border));
}
/* Fenced code blocks (react-syntax-highlighter wraps in <pre>) */
.markdown-preview pre {
background-color: hsl(222 40% 4%) !important;
border: 1px solid hsl(var(--border));
border-radius: 8px;
padding: 1rem !important;
overflow-x: auto;
margin: 0 0 1rem;
box-shadow: inset 0 0 40px -20px hsl(var(--primary) / 0.25);
}
.markdown-preview pre code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.85rem;
background: transparent;
border: none;
padding: 0;
}
/* Tables */
.markdown-preview table {
width: 100%;
border-collapse: collapse;
margin: 0 0 1rem;
font-size: 0.9rem;
}
.markdown-preview th,
.markdown-preview td {
border: 1px solid hsl(var(--border));
padding: 0.6rem 0.8rem;
text-align: left;
}
.markdown-preview th {
background-color: hsl(var(--muted));
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--muted-foreground));
}
.markdown-preview tr:hover td {
background: hsl(var(--accent) / 0.5);
}
/* KaTeX on dark */
.katex-display {
margin: 1.25em 0;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
}
.katex {
font-size: 1.1rem;
color: hsl(var(--foreground));
}

View File

@@ -0,0 +1,118 @@
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 rehypeSanitize, { defaultSchema } from "rehype-sanitize";
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;
};
// Markdown content is authored by users and rendered for everyone
// (including the unauthenticated /pg/* route), so raw HTML must be
// sanitized to prevent stored XSS. className is kept on code/span/div so
// syntax highlighting and KaTeX (which runs after sanitize) still work;
// scripts, event handlers and javascript: URLs are stripped.
const sanitizeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
code: [...(defaultSchema.attributes?.code || []), ["className"]],
span: [...(defaultSchema.attributes?.span || []), ["className"]],
div: [...(defaultSchema.attributes?.div || []), ["className"]],
},
};
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={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
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,214 @@
import React, { useState } from "react";
import { Plus, Pencil, Trash2, Save, Layers } from "lucide-react";
import MarkdownView from "./MarkdownView";
import PermissionGuard from "../PermissionGuard";
import {
usePatches,
useCreatePatch,
useUpdatePatch,
useDeletePatch,
} from "../../utils/queries/patch-queries";
import { Button } from "../ui/button";
import { Input, Textarea, Label } from "../ui/input";
import { Spinner } from "../ui/misc";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
const PatchCards = ({ markdownId }) => {
const { data: patches, isLoading, isError } = usePatches(markdownId);
const createPatch = useCreatePatch();
const updatePatch = useUpdatePatch();
const deletePatch = useDeletePatch();
// editor dialog state — `editing` is null for "create", or the patch object
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const openCreate = () => {
setEditing(null);
setTitle("");
setContent("");
setOpen(true);
};
const openEdit = (patch) => {
setEditing(patch);
setTitle(patch.title || "");
setContent(patch.content || "");
setOpen(true);
};
const handleSave = () => {
if (!content.trim()) {
alert("Patch content cannot be empty");
return;
}
const payload = { title: title.trim() || null, content };
if (editing) {
updatePatch.mutate(
{ id: editing.id, data: payload },
{
onSuccess: () => setOpen(false),
onError: () => alert("Failed to update patch"),
}
);
} else {
createPatch.mutate(
{ markdown_id: markdownId, ...payload },
{
onSuccess: () => setOpen(false),
onError: () => alert("Failed to create patch"),
}
);
}
};
const handleDelete = (patch) => {
if (!window.confirm("Delete this patch card?")) return;
deletePatch.mutate(
{ id: patch.id, markdownId },
{ onError: () => alert("Failed to delete patch") }
);
};
// Non-admins on a restricted parent get an error here — fail silently so
// the main markdown page is never broken by patches.
if (isError) return null;
const list = patches || [];
const saving = createPatch.isPending || updatePatch.isPending;
return (
<section className="mt-10">
{(isLoading || list.length > 0) && (
<div className="mb-4 flex items-center gap-2">
<Layers className="h-4 w-4 text-secondary" />
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Patch Cards
{list.length > 0 && (
<span className="ml-2 text-secondary">
{list.length}
</span>
)}
</h2>
<div className="h-px flex-1 bg-border" />
</div>
)}
{isLoading && <Spinner label="Loading patches" />}
<div className="space-y-5">
{list.map((patch, i) => (
<div
key={patch.id}
className="group relative rounded-lg border border-border bg-card/60 shadow-[0_0_24px_-16px_hsl(var(--secondary)/0.6)]"
>
<div className="flex items-center justify-between gap-3 border-b border-border/70 px-5 py-2.5">
<span className="font-mono text-xs font-semibold uppercase tracking-wide text-secondary">
{patch.title || `Patch ${i + 1}`}
</span>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
title="Edit"
onClick={() => openEdit(patch)}
className="grid h-7 w-7 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<PermissionGuard rolesRequired={["admin"]}>
<button
type="button"
title="Delete"
onClick={() => handleDelete(patch)}
className="grid h-7 w-7 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</PermissionGuard>
</div>
</PermissionGuard>
</div>
<div className="px-5 py-4">
<MarkdownView content={{ markdown: patch.content }} />
</div>
</div>
))}
</div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<Button
variant="outline"
size="sm"
onClick={openCreate}
className="mt-5 w-full border-dashed text-muted-foreground hover:text-secondary"
>
<Plus className="h-4 w-4" /> Add Patch
</Button>
</PermissionGuard>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{editing ? "Edit Patch Card" : "New Patch Card"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="patch-title">
Title (optional)
</Label>
<Input
id="patch-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Update 2026-05"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="patch-content">
Content (markdown)
</Label>
<Textarea
id="patch-content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write markdown…"
className="min-h-[220px] font-mono text-xs"
/>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setOpen(false)}
disabled={saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4" />
{saving ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
};
export default PatchCards;

View File

@@ -0,0 +1,111 @@
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";
import { parseMarkdownContent } from "../../utils/safe-json";
import { Spinner } from "../ui/misc";
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 className="flex min-h-screen items-center justify-center">
<Spinner label="Loading" />
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center px-6">
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
Error: {error.message || "Failed to load content"}
</div>
</div>
);
}
if (!notReady && !markdownId) {
return (
<div className="flex min-h-screen items-center justify-center px-6">
<p className="font-mono text-sm text-muted-foreground">
Markdown not found for path:{" "}
<span className="text-foreground">{pathString}</span>
</p>
</div>
);
}
if (markdown?.isMessage) {
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<div className="rounded-lg border border-primary/30 bg-primary/10 px-5 py-4">
<h4 className="mb-1 font-mono text-base font-semibold text-primary">
{markdown.title}
</h4>
<p className="text-sm text-foreground/80">
{markdown.content}
</p>
</div>
</div>
);
}
return (
<div className="relative z-10 mx-auto max-w-4xl px-6 py-12">
<h1 className="mb-8 border-b border-border pb-4 font-mono text-3xl font-bold tracking-tight text-foreground">
{markdown?.title === "index" ? indexTitle : markdown?.title}
</h1>
{markdown && (
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template} />
)}
</div>
);
};
export default StandaloneMarkdownPage;

View File

@@ -0,0 +1,179 @@
import React, {useState} from "react";
import { Plus, Trash2 } from "lucide-react";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
import { Input, Textarea, Label } from "../ui/input";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
const FIELD_BOX = "space-y-2 rounded-md border border-border bg-surface/40 p-4";
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={FIELD_BOX}>
<Label>{__namespace}</Label>
<Input
type="text"
value={value ?? ""}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
</div>
);
case "markdown":
return (
<div className={FIELD_BOX}>
<Label>{__namespace}</Label>
<Textarea
className="max-h-[10vh] font-mono text-sm"
value={value}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
</div>
);
case "enum":
return (
<div className={FIELD_BOX}>
<Label>{__namespace}</Label>
<select
className={SELECT_CLASS}
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>
);
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={FIELD_BOX}>
<Label>{__namespace}</Label>
{cache.map((item, idx) => (
<div className="flex items-start gap-2" key={idx}>
<div className="flex-1">
<TemplatedEditorComponent
variable={{ name: idx, type: variable.type.extend_type }}
value={item}
namespace={__namespace}
onContentChanged={(subKey, subVal) => onItemChange(idx, subVal)}
/>
</div>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => removeItem(idx)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
);
}
case "template": {
const { data: _template, isFetching: loading } = useMarkdownTemplate(variable.type.definition.template.id);
if (loading) return <Spinner label="Loading template" />;
const _parameters = _template.parameters;
const handleSubChange = (key, val) => {
const updated = { ...(value || {}), [key]: val };
onContentChanged(variable.name, updated);
};
return (
<div className={FIELD_BOX}>
<Label>{__namespace}</Label>
{_parameters.map((param, i) => (
<div 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="flex flex-col overflow-hidden rounded-lg border border-border bg-card"
style={style}
>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{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,168 @@
import React, { useState } from 'react';
import { Plus, Trash2, Copy, KeyRound } from 'lucide-react';
import { useCreateApiKey } from '../../utils/queries/apikey-queries';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Input, Label } from '../ui/input';
const AVAILABLE_ROLES = ['guest', 'creator', 'admin'];
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50";
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)
);
};
return (
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
</DialogHeader>
{!generatedKey ? (
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="api-key-name">Name</Label>
<Input
id="api-key-name"
type="text"
placeholder="API key name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Roles</Label>
<div className="space-y-2">
{roles.map((role, index) => (
<div key={index} className="flex items-center gap-2">
<select
className={SELECT_CLASS}
value={role}
onChange={(e) => handleRoleChange(index, e.target.value)}
>
{getRemainingRoles(index).map(availableRole => (
<option key={availableRole} value={availableRole}>
{availableRole}
</option>
))}
</select>
<Button
variant="destructive"
size="icon"
onClick={() => handleRemoveRole(index)}
disabled={roles.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddRole}
disabled={roles.length === AVAILABLE_ROLES.length}
>
<Plus className="h-4 w-4" /> Add Role
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="rounded-lg border border-secondary/40 bg-secondary/10 px-4 py-3 font-mono text-sm text-secondary">
Please copy your API key immediately! It will only be displayed once!
</div>
<div className="space-y-2">
<Label htmlFor="generated-api-key">Your API Key</Label>
<Input
id="generated-api-key"
className="font-mono"
type="text"
value={generatedKey.key}
readOnly
/>
</div>
<Button onClick={handleCopy}>
<Copy className="h-4 w-4" /> Copy
</Button>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
{!generatedKey && (
<Button
onClick={handleGenerate}
disabled={createApiKeyMutation.isLoading || !name.trim()}
>
<KeyRound className="h-4 w-4" /> Generate
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ApiKeyCreationModal;

View File

@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { useRevokeApiKey } from '../../utils/queries/apikey-queries';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Input, Label } from '../ui/input';
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');
}
};
return (
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Revoke API Key</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="revoke-api-key">API Key</Label>
<Input
id="revoke-api-key"
type="text"
placeholder="Enter API key to revoke"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleRevoke}
disabled={revokeApiKeyMutation.isLoading}
>
<Trash2 className="h-4 w-4" /> Revoke
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ApiKeyRevokeModal;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Copy } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Textarea } from '../ui/input';
const JsonSchemaModal = ({ isActive, onClose, schema }) => {
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(schema, null, 2));
};
return (
<Dialog open={isActive} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>JSON Schema</DialogTitle>
</DialogHeader>
<Textarea
className="h-[50vh] font-mono text-xs"
value={JSON.stringify(schema, null, 2)}
readOnly
/>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
<Button onClick={handleCopy}>
<Copy className="h-4 w-4" /> Copy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default JsonSchemaModal;

View File

@@ -0,0 +1,79 @@
import {useCreateMarkdownSetting, useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
import {useSaveMarkdown} from "../../utils/queries/markdown-queries";
import React, {useState} from "react";
import { Plus } from "lucide-react";
import MarkdownTemplateSettingPanel from "../Settings/MarkdownSettings/MarkdownTemplateSettingPanel";
import MarkdownPermissionSettingPanel from "../Settings/MarkdownSettings/MarkdownPermissionSettingPanel";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
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
}
});
}
});
};
return (
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Markdown Settings</DialogTitle>
</DialogHeader>
{markdownSettingIsFetching ? (
<div className="flex justify-center py-10">
<Spinner label="Loading settings" />
</div>
) : markdownSetting ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="template">Template</TabsTrigger>
<TabsTrigger value="permission">Permission</TabsTrigger>
</TabsList>
<TabsContent value="template">
<MarkdownTemplateSettingPanel
markdownSetting={markdownSetting}
onClose={onClose}
/>
</TabsContent>
<TabsContent value="permission">
<MarkdownPermissionSettingPanel
markdownSetting={markdownSetting}
onClose={onClose}
/>
</TabsContent>
</Tabs>
) : (
<Button
type="button"
onClick={handleCreateMarkdownSetting}
>
<Plus className="h-4 w-4" /> Create Markdown Setting
</Button>
)}
</DialogContent>
</Dialog>
);
};
export default MarkdownSettingModal;

View File

@@ -0,0 +1,71 @@
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";
import { Plus } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
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}});
}
});
};
return (
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Path Settings</DialogTitle>
</DialogHeader>
{settingId && isPathSettingLoading ? (
<div className="flex justify-center py-10">
<Spinner label="Loading settings" />
</div>
) : !pathSetting ? (
<Button
type="button"
onClick={handleCreatePathSetting}
>
<Plus className="h-4 w-4" /> Create Path Setting
</Button>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="webhook">Webhook</TabsTrigger>
<TabsTrigger value="template">Template</TabsTrigger>
</TabsList>
<TabsContent value="webhook">
<WebhookSettingPanel
pathSetting={pathSetting}
onClose={onClose}
/>
</TabsContent>
<TabsContent value="template">
<div></div>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);
};
export default PathSettingModal;

View File

@@ -1,10 +1,13 @@
/*src/components/MainNavigation.css*/
.main-navigation {
background-color: #333;
color: white;
padding: 1rem;
}
.dropdown-option {
margin-top: 0.2rem;
}
.main-navigation .navbar {
background-color: #333 !important;
}
@@ -19,6 +22,10 @@
color: #f5f5f5 !important;
}
.navbar-end .navbar-item {
margin-right: 1rem;
}
.main-navigation .button {
margin-left: 0.5rem;
}
@@ -54,4 +61,4 @@
.main-navigation .navbar-item:not(:last-child) {
border-right: 1px solid #555;
padding-right: 1rem;
}
}

View File

@@ -1,87 +1,225 @@
import React, { useContext } from "react";
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 {
Download,
Upload,
KeyRound,
KeySquare,
LogOut,
LogIn,
ChevronDown,
Mail,
GitBranch,
} from "lucide-react";
import ApiKeyCreationModal from "../Modals/ApiKeyCreationModal";
import ApiKeyRevokeModal from "../Modals/ApiKeyRevokeModal";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "../ui/dropdown-menu";
const MainNavigation = () => {
const { user, login, logout } = useContext(AuthContext);
const config = useConfig();
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
if (config === undefined) {
return (
<header className="flex h-14 items-center border-b border-border px-5 text-sm text-muted-foreground">
Loading
</header>
);
}
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) {
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="/">
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");
}}
<>
<header className="glass relative z-20 flex h-14 shrink-0 items-center gap-1 border-b border-border px-4">
<Link
to="/"
className="group flex items-center gap-2.5 pr-3"
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<img
src="/icons/logo.png"
alt="Logo"
className="h-7 w-7 rounded"
/>
<span className="font-mono text-sm font-semibold tracking-tight">
<span className="text-foreground">HANGMAN</span>
<span className="neon-text">//LAB</span>
</span>
</Link>
<div id="navbarBasicExample" className="navbar-menu">
<div className="navbar-start">
<nav className="ml-2 hidden items-center gap-1 sm:flex">
<a
href="https://mail.hangman-lab.top"
className="navbar-item"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
MailBox
<Mail className="h-3.5 w-3.5" /> Mail
</a>
<a
href="https://git.hangman-lab.top"
className="navbar-item"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Git
<GitBranch className="h-3.5 w-3.5" /> Git
</a>
</div>
</nav>
<div className="navbar-end">
<div className="ml-auto flex items-center">
{user && user.profile ? (
<div className="navbar-item">
<div className="buttons">
<span className="button is-primary is-light">
{user.profile.name}
</span>
<button
className="button is-danger"
onClick={logout}
type="button"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2"
>
Logout
</button>
</div>
</div>
<span className="grid h-5 w-5 place-items-center rounded-full bg-primary/15 font-mono text-[10px] font-bold text-primary">
{(user.profile.name || "?")
.charAt(0)
.toUpperCase()}
</span>
<span className="max-w-[10rem] truncate">
{user.profile.name}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{user.profile.name}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleGetBackup}>
<Download /> Get Backup
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLoadBackup}>
<Upload /> Load Backup
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setIsApiKeyModalOpen(true)}
>
<KeyRound /> Create API Key
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setIsRevokeModalOpen(true)}
>
<KeySquare /> Revoke API Key
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={logout}
className="text-destructive focus:text-destructive [&_svg]:text-destructive"
>
<LogOut /> Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="navbar-item">
<button
className="button is-primary"
onClick={login}
type="button"
>
Login
</button>
</div>
<Button size="sm" onClick={login} className="gap-2">
<LogIn className="h-4 w-4" /> Login
</Button>
)}
</div>
</div>
</nav>
</header>
<ApiKeyCreationModal
isOpen={isApiKeyModalOpen}
onClose={() => setIsApiKeyModalOpen(false)}
/>
<ApiKeyRevokeModal
isOpen={isRevokeModalOpen}
onClose={() => setIsRevokeModalOpen(false)}
/>
</>
);
};
export default MainNavigation;
export default MainNavigation;

View File

@@ -0,0 +1,92 @@
import { Link, useNavigate } from "react-router-dom";
import PermissionGuard from "../PermissionGuard";
import React, { useState } from "react";
import { FileText, Settings2, Trash2, ChevronUp, ChevronDown } from "lucide-react";
import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
import { useDeleteMarkdown } from "../../utils/queries/markdown-queries";
import { useDeleteMarkdownSetting } from "../../utils/queries/markdown-setting-queries";
import { cn } from "../../lib/utils";
const iconBtn =
"grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary";
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>
<div className="group flex items-center gap-1 rounded-md px-1 py-1 pl-1.5 transition-colors hover:bg-accent/60">
<FileText className="ml-5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Link
to={`/markdown/${markdown.id}`}
className="min-w-0 flex-1 truncate text-sm text-foreground/90 transition-colors hover:text-primary"
>
{markdown.title}
</Link>
<PermissionGuard rolesRequired={['admin']}>
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className={iconBtn}
title="Settings"
onClick={() => setIsMarkdownSettingModalOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={cn(iconBtn, "hover:text-destructive")}
title="Delete"
onClick={handleDeleteMarkdown}
disabled={deleteMarkdown.isLoading || deleteMarkdownSetting.isLoading}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="flex flex-col">
<button
type="button"
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
onClick={() => handleMoveMarkdown(markdown, "forward")}
title="Move up"
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
onClick={() => handleMoveMarkdown(markdown, "backward")}
title="Move down"
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
</div>
<MarkdownSettingModal
isOpen={isMarkdownSettingModalOpen}
markdown={markdown}
onClose={() => setIsMarkdownSettingModalOpen(false)}
/>
</PermissionGuard>
</div>
</li>
);
};
export default MarkdownNode;

View File

@@ -8,6 +8,18 @@
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;
@@ -35,3 +47,35 @@
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

@@ -1,221 +1,227 @@
import React, { useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { toggleNodeExpansion } from '../../store/navigationSlice';
import { Link } from "react-router-dom";
import {
ChevronRight,
Folder,
FolderOpen,
Settings2,
Pencil,
Check,
Trash2,
ChevronUp,
ChevronDown,
} from "lucide-react";
import PermissionGuard from "../PermissionGuard";
import config from "../../config";
import "./PathNode.css";
import {fetch_} from "../../utils/requestUtils";
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";
import { cn } from "../../lib/utils";
const iconBtn =
"grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary";
const PathNode = ({ path, isRoot = false }) => {
const [children, setChildren] = useState([]);
const [markdowns, setMarkdowns] = useState([]);
const [isExpanded, setIsExpanded] = useState(isRoot); // Root is always expanded
const [loading, setLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false); // Track if editing
const [editData, setEditData] = useState({
name: path.name,
parent_id: path.parent_id,
});
const [isPathSettingModalOpen, setIsPathSettingModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(path.name || "");
const toggleExpand = () => {
if (isRoot || isExpanded) {
setIsExpanded(false);
return;
}
const expandedNodes = useSelector(state => state.navigation.expandedNodes);
const dispatch = useDispatch();
const isExpanded = isRoot || expandedNodes[path.id];
setIsExpanded(true);
const deletePath = useDeletePath();
const updatePath = useUpdatePath();
const { data: indexMarkdown } = useIndexMarkdown(path.id);
const movePath = useMovePath();
const moveMarkdown = useMoveMarkdown();
if (children.length === 0 && markdowns.length === 0) {
setLoading(true);
fetch_(`${config.BACKEND_HOST}/api/path/parent/${path.id}`, {}, {
use_cache: true,
use_token: false
})
.then((childPaths) => {
setChildren(childPaths);
return fetch_(
`${config.BACKEND_HOST}/api/markdown/by_path/${path.id}`
);
})
.then((markdownData) => setMarkdowns(markdownData))
.catch((error) => console.error(error))
.finally(() => setLoading(false));
}
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: () => alert("failed to update this path"),
});
};
const handleDelete = () => {
if (window.confirm(`Are you sure you want to delete ${path.name}?`)) {
fetch_(`${config.BACKEND_HOST}/api/path/${path.id}`, {
method: "DELETE"
}, {
use_cache: false,
use_token: true
})
.then((response) => {
if (response.ok) {
alert("Path deleted successfully!");
setChildren([]);
setMarkdowns([]);
} else {
response.json().then((data) => {
alert(data.error || "Failed to delete path.");
});
}
})
.catch((error) => console.error(error));
if (window.confirm("Are you sure?")) {
deletePath.mutate(path.id, {
onError: () => alert("failed to delete this path"),
});
}
};
const handleEdit = () => setIsEditing(true);
const handleEdit = () => {
setIsEditing(true);
const handleMovePath = (pth, direction) => {
movePath.mutate({ path: pth, direction }, {
onError: () => alert("failed to move this path"),
});
};
const handleMoveMarkdown = (md, direction) => {
moveMarkdown.mutate({ markdown: md, direction }, {
onError: () => alert("failed to move this markdown"),
});
};
const handleEditSubmit = (e) => {
e.preventDefault();
fetch_(`${config.BACKEND_HOST}/api/path/${path.id}`, {
method: "PUT",
body: JSON.stringify(editData),
},{
use_cache: false,
use_token: true
})
.then((response) => {
if (response.ok) {
alert("Path updated successfully!");
path.name = editData.name;
path.parent_id = editData.parent_id;
setIsEditing(false);
} else {
response.json().then((data) => {
alert(data.error || "Failed to update path.");
});
}
})
.catch((error) => console.error(error));
};
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))
: [];
const handleEditCancel = () => {
setEditData({ name: path.name, parent_id: path.parent_id });
setIsEditing(false);
};
if (isRoot)
return (
<ul className="space-y-0.5">
{sortedPaths.map((p) => (
<PathNode key={p.id} path={p} isRoot={false} />
))}
{sortedMarkdowns.map((markdown) => (
<MarkdownNode
markdown={markdown}
handleMoveMarkdown={handleMoveMarkdown}
key={markdown.id}
/>
))}
</ul>
);
return (
<li>
<div className="path-node-header">
<span
className={`has-text-weight-bold ${
isRoot ? "" : "is-clickable"
}`}
onClick={isRoot ? undefined : toggleExpand}
style={{ cursor: isRoot ? "default" : "pointer" }}
<div className="group flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-accent/60">
<button
type="button"
onClick={toggleExpand}
title={isExpanded ? "Collapse" : "Expand"}
className="flex shrink-0 items-center gap-1 rounded text-muted-foreground transition-colors hover:text-primary"
>
{isExpanded && !isRoot ? "▼ " : isRoot ? "" : "▶ "}
{path.name}
</span>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform",
isExpanded && "rotate-90"
)}
/>
{isExpanded ? (
<FolderOpen className="h-4 w-4 text-secondary" />
) : (
<Folder className="h-4 w-4 text-secondary/70" />
)}
</button>
{/* Admin controls */}
<PermissionGuard rolesRequired={["admin"]}>
<span className="path-node-actions">
<button
onClick={handleEdit}
className="button is-small is-info is-light"
type="button"
>
Edit
</button>
<button
onClick={handleDelete}
className="button is-small is-danger is-light"
type="button"
>
Delete
</button>
{isEditing ? (
<input
autoFocus
className="h-6 min-w-0 flex-1 rounded border border-input bg-background/60 px-1.5 text-xs text-foreground focus:border-primary/60 focus:outline-none"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
) : indexMarkdown ? (
// Clicking the name navigates to the folder's index page
// AND expands the subtree (expanded state is global, so
// the children stay visible after navigation).
<Link
to={`/markdown/${indexMarkdown.id}`}
onClick={expand}
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground transition-colors hover:text-primary"
>
{path.name}
</Link>
) : (
<span
onClick={toggleExpand}
className="min-w-0 flex-1 cursor-pointer truncate text-sm font-medium text-foreground"
>
{path.name}
</span>
)}
<PermissionGuard rolesRequired={["admin"]}>
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className={iconBtn}
title="Settings"
onClick={() => setIsPathSettingModalOpen(true)}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
{isEditing ? (
<button
type="button"
className={iconBtn}
title="Save"
onClick={handleSave}
>
<Check className="h-3.5 w-3.5 text-primary" />
</button>
) : (
<button
type="button"
className={iconBtn}
title="Rename"
onClick={handleEdit}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
className={cn(iconBtn, "hover:text-destructive")}
title="Delete"
onClick={handleDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="flex flex-col">
<button
type="button"
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
onClick={() => handleMovePath(path, "forward")}
title="Move up"
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
onClick={() => handleMovePath(path, "backward")}
title="Move down"
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
</div>
<PathSettingModal
isOpen={isPathSettingModalOpen}
path={path}
onClose={() => setIsPathSettingModalOpen(false)}
/>
</PermissionGuard>
</div>
{isExpanded && (
<ul>
{/* Render child paths */}
{loading && <p>Loading...</p>}
{children.map((child) => (
<ul className="ml-3 space-y-0.5 border-l border-border/60 pl-2">
{sortedPaths.map((child) => (
<PathNode key={child.id} path={child} />
))}
{/* Render markdowns */}
{markdowns.map((markdown) => (
<li key={markdown.id} className="menu-list-item">
<Link
to={`/markdown/${markdown.id}`}
className="is-link"
>
{markdown.title}
</Link>
</li>
{sortedMarkdowns.map((markdown) => (
<MarkdownNode
markdown={markdown}
handleMoveMarkdown={handleMoveMarkdown}
key={markdown.id}
/>
))}
</ul>
)}
{/* Edit Popup */}
{isEditing && (
<div className="popup">
<div className="popup-content">
<form onSubmit={handleEditSubmit}>
<h3>Edit Path</h3>
<div className="field">
<label className="label">Name</label>
<div className="control">
<input
className="input"
type="text"
value={editData.name}
onChange={(e) =>
setEditData({
...editData,
name: e.target.value,
})
}
required
/>
</div>
</div>
<div className="field">
<label className="label">Parent ID</label>
<div className="control">
<input
className="input"
type="number"
value={editData.parent_id}
onChange={(e) =>
setEditData({
...editData,
parent_id: Number(
e.target.value
),
})
}
required
/>
</div>
</div>
<div className="buttons">
<button
type="submit"
className="button is-primary"
>
Save
</button>
<button
type="button"
className="button is-light"
onClick={handleEditCancel}
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</li>
);
};

View File

@@ -1,9 +1,11 @@
/* src/components/SideNavigation.css */
.menu {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background-color: #f9f9f9;
height: 90vh;
overflow-y: hidden;
min-width: 15vw;
}
.menu-label {
@@ -14,6 +16,8 @@
.menu-list {
margin-left: 0;
height: 100%;
overflow-y: auto;
}
.menu-list-item {
@@ -45,3 +49,34 @@
.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

@@ -1,44 +1,62 @@
import React, { useEffect, useState } from "react";
import PermissionGuard from "../PermissionGuard";
import PathNode from "./PathNode";
import config from "../../config";
import "./SideNavigation.css";
import {fetch_} from "../../utils/requestUtils";
import React, { useContext, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { setSelectedTab } from '../../store/navigationSlice';
import { FolderTree, LayoutTemplate } from "lucide-react";
import TreeTab from "./SideTabs/TreeTab";
import TemplateTab from "./SideTabs/TemplateTab";
import { AuthContext } from "../../AuthProvider";
import { ScrollArea } from "../ui/scroll-area";
import { cn } from "../../lib/utils";
const SideNavigation = () => {
const [paths, setPaths] = useState([]);
const [loading, setLoading] = useState(false);
const { roles } = useContext(AuthContext);
const selectedTab = useSelector(state => state.navigation.selectedTab);
const dispatch = useDispatch();
const allTabs = [
{ id: "tree", label: "Tree", icon: FolderTree, component: <TreeTab /> },
{ id: "templates", label: "Templates", icon: LayoutTemplate, component: <TemplateTab /> },
];
const visibleTabs = roles.includes("admin")
? allTabs
: allTabs.filter(tab => tab.id === "tree");
useEffect(() => {
setLoading(true);
fetch_(`${config.BACKEND_HOST}/api/path/`, {},{ use_cache: true, use_token:false })
.then((data) => {
setPaths(data);
})
.catch((error) => console.log(error))
.finally(() => setLoading(false));
}, []);
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="menu">
<p className="menu-label">Markdown Directory</p>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<a
href="/markdown/create"
className="button is-primary is-small"
>
Create New Markdown
</a>
</PermissionGuard>
<ul className="menu-list">
{loading && <p>Loading...</p>}
{paths.map((path) => (
<PathNode
key={path.id}
path={path}
isRoot={true}
/>
))}
</ul>
<aside className="flex w-72 shrink-0 flex-col border-r border-border bg-surface/40">
<div className="flex items-center gap-1 border-b border-border p-2">
{visibleTabs.map(tab => {
const Icon = tab.icon;
const active = tab.id === selectedTab;
return (
<button
key={tab.id}
type="button"
onClick={() => dispatch(setSelectedTab(tab.id))}
className={cn(
"inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 font-mono text-xs font-medium uppercase tracking-wide transition-all",
active
? "bg-primary/15 text-primary shadow-[0_0_12px_-4px_hsl(var(--primary)/0.6)]"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
<Icon className="h-3.5 w-3.5" />
{tab.label}
</button>
);
})}
</div>
<ScrollArea className="flex-1">
<div className="p-3">{current?.component}</div>
</ScrollArea>
</aside>
);
};

View File

@@ -0,0 +1,184 @@
import React, { useState } from "react";
import { useMarkdownTemplates } from "../../../utils/queries/markdown-template-queries";
import PermissionGuard from "../../PermissionGuard";
import { Link, useNavigate } from "react-router-dom";
import { Search, LayoutTemplate, Pencil, Braces } from "lucide-react";
import JsonSchemaModal from "../../Modals/JsonSchemaModal";
import { Input } from "../../ui/input";
import { Button } from "../../ui/button";
import { Spinner } from "../../ui/misc";
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 <Spinner label="Loading templates" />;
if (error)
return (
<p className="font-mono text-xs text-destructive">
Error loading templates
</p>
);
return (
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8 text-xs"
type="text"
placeholder="Search templates…"
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<Button asChild size="sm" variant="outline" className="w-full">
<Link to="/template/create">
<LayoutTemplate className="h-4 w-4" /> New Template
</Link>
</Button>
</PermissionGuard>
<ul className="space-y-0.5">
{filteredTemplates?.map((template) => (
<li
key={template.id}
className="group flex items-center gap-1 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/60"
>
<LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-secondary/80" />
<span className="min-w-0 flex-1 truncate text-sm text-foreground/90">
{template.title}
</span>
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
title="Edit"
onClick={() => handleTemplateClick(template.id)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
title="JSON schema"
onClick={() => setSelectedSchema(generateJsonSchema(template))}
>
<Braces className="h-3.5 w-3.5" />
</button>
</div>
</li>
))}
</ul>
<JsonSchemaModal
isActive={selectedSchema !== null}
onClose={() => setSelectedSchema(null)}
schema={selectedSchema}
/>
</div>
);
};
export default TemplateTab;

View File

@@ -0,0 +1,100 @@
import PermissionGuard from "../../PermissionGuard";
import PathNode from "../PathNode";
import React from "react";
import { Link } from "react-router-dom";
import { Search, FilePlus2 } from "lucide-react";
import { useTree } from "../../../utils/queries/tree-queries";
import { useDeletePath, useUpdatePath } from "../../../utils/queries/path-queries";
import { Input } from "../../ui/input";
import { Button } from "../../ui/button";
import { Spinner } from "../../ui/misc";
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: () => 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: () => alert("Failed to update path"),
});
};
if (isLoading) return <Spinner label="Loading tree" />;
if (error)
return (
<p className="font-mono text-xs text-destructive">
Error loading tree
</p>
);
return (
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8 text-xs"
type="text"
placeholder="Search…"
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<Button
asChild
size="sm"
variant="outline"
className="w-full"
>
<Link to="/markdown/create">
<FilePlus2 className="h-4 w-4" /> New Markdown
</Link>
</Button>
</PermissionGuard>
{!filteredTree || filteredTree.length === 0 ? (
<p className="px-1 py-6 text-center font-mono text-xs text-muted-foreground">
No result
</p>
) : (
<PathNode
key={1}
path={filteredTree}
isRoot={true}
onSave={handleSave}
onDelete={handleDelete}
/>
)}
</div>
);
};
export default TreeTab;

View File

@@ -1,39 +1,5 @@
.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);
/* Styling moved to Tailwind / dark-tech design system in PathManager.js.
Only the scroll cap for the suggestions dropdown remains here. */
.path-manager-dropdown {
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

@@ -1,41 +1,65 @@
import React, { useEffect, useState, useRef } from "react";
import { fetch_ } from "../utils/requestUtils";
import config from "../config";
import React, {useEffect, useState, useRef, useContext} from "react";
import {useCreatePath, usePaths} from "../utils/queries/path-queries";
import { useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import "./PathManager.css";
import {fetch_} from "../utils/request-utils";
import {ConfigContext} from "../ConfigProvider";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
import { Spinner } from "./ui/misc";
const PathManager = ({ currentPathId = 1, onPathChange }) => {
const [currentPath, setCurrentPath] = useState([{ name: "Root", id: 1 }]);
const [currentId, setCurrentId] = useState(currentPathId);
const [subPaths, setSubPaths] = useState([]);
const [currentFullPath, setCurrentFullPath] = useState([{ name: "Root", id: 1 }]);
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(false);
const [dropdownActive, setDropdownActive] = useState(false);
const inputRef = useRef();
useEffect(() => {
fetchSubPaths(currentId);
}, [currentId]);
const queryClient = useQueryClient();
const { data: subPaths, isLoading: isSubPathsLoading, error: subPathsError } = usePaths(currentPathId);
const createPath = useCreatePath();
const config = useContext(ConfigContext).config;
const fetchSubPaths = (pathId) => {
setLoading(true);
fetch_(`${config.BACKEND_HOST}/api/path/parent/${pathId}`, {}, { use_cache: false, use_token: true })
.then((data) => setSubPaths(data))
.catch((error) => console.error("Failed to fetch subdirectories:", error))
.finally(() => setLoading(false));
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 = currentPath.slice(0, pathIndex + 1);
setCurrentPath(newPath);
setCurrentId(pathId);
const newPath = currentFullPath.slice(0, pathIndex + 1);
setCurrentFullPath(newPath);
onPathChange(pathId);
};
const handleSubPathSelect = (subPath) => {
const updatedPath = [...currentPath, { name: subPath.name, id: subPath.id }];
setCurrentPath(updatedPath);
setCurrentId(subPath.id);
const updatedPath = [...currentFullPath, { name: subPath.name, id: subPath.id }];
setCurrentFullPath(updatedPath);
onPathChange(subPath.id);
setSearchTerm("");
setDropdownActive(false);
@@ -46,92 +70,107 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
alert("Directory name cannot be empty.");
return;
}
fetch_(`${config.BACKEND_HOST}/api/path/`, {
method: "POST",
body: JSON.stringify({ name: searchTerm.trim(), parent_id: currentId }),
}, { use_cache: false, use_token: true })
.then((newDir) => {
setSubPaths([...subPaths, newDir]);
setSearchTerm("");
alert("Directory created successfully!");
})
.catch((error) => {
console.error("Failed to create directory:", error);
alert("Failed to create directory.");
});
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.filter((path) =>
path.name.toLowerCase().includes(searchTerm.toLowerCase())
);
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">
<div className="current-path">
{currentPath.map((path, index) => (
<span
key={path.id}
className="breadcrumb-item is-clickable"
onClick={() => handlePathClick(path.id, index)}
>
{path.name}
{index < currentPath.length - 1 && " / "}
</span>
))}
</div>
<div className="path-manager space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{currentFullPath.map((path, index) => (
<button
type="button"
key={path.id}
className="rounded-md border border-border bg-surface/60 px-2 py-1 font-mono text-xs text-primary transition-colors hover:border-primary/60"
onClick={() => handlePathClick(path.id, index)}
>
{path.name + "/"}
</button>
))}
</div>
<div className="path-manager-body">
<div className="field has-addons">
<div className="control">
<input
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
ref={inputRef}
className="input is-small"
className="h-8 text-xs"
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={loading || !searchTerm.trim()}
type="button"
>
Create "{searchTerm}"
</button>
</div>
</div>
{dropdownActive && (
<div className="dropdown is-active">
<div className="dropdown-menu">
<div className="dropdown-content">
{dropdownActive && (
<div className="path-manager-dropdown absolute left-0 top-full z-20 mt-1 w-full overflow-y-auto rounded-md border border-border bg-card shadow-glow">
{filteredSubPaths.length > 0 ? (
filteredSubPaths.map((subPath) => (
<a
<button
type="button"
key={subPath.id}
className="dropdown-item"
className="block w-full px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-accent"
onClick={() => handleSubPathSelect(subPath)}
>
{subPath.name}
</a>
</button>
))
) : (
<div className="dropdown-item">No matches found</div>
<div className="px-3 py-2 text-xs text-muted-foreground">
No matches found
</div>
)}
</div>
</div>
)}
</div>
<Button
type="button"
size="sm"
onClick={handleAddDirectory}
disabled={isSubPathsLoading || !searchTerm.trim()}
>
<Plus className="h-4 w-4" /> Create "{searchTerm}"
</Button>
</div>
{isSubPathsLoading && <Spinner label="Loading paths" />}
{subPathsError && (
<p className="font-mono text-xs text-destructive">
Error loading subdirectories.
</p>
)}
</div>
</div>

View File

@@ -0,0 +1,102 @@
import {
useCreateMarkdownPermissionSetting,
useMarkdownPermissionSetting,
useUpdateMarkdownPermissionSetting
} from "../../../utils/queries/markdown-permission-setting-queries";
import React, {useEffect, useState} from "react";
import { Plus, Save } from "lucide-react";
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries";
import { Button } from "../../ui/button";
import { Label } from "../../ui/input";
import { Spinner } from "../../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
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 (
<div className="flex justify-center py-6">
<Spinner label="Loading permission" />
</div>
);
}
return setting ? (
<div className="mt-4 space-y-4 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Permission Setting
</h4>
<div className="space-y-2">
<Label htmlFor="permission-select">Permission</Label>
<select
id="permission-select"
className={SELECT_CLASS}
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>
<Button
type="button"
onClick={handleSaveMarkdownPermissionSetting}
>
<Save className="h-4 w-4" /> Save Permission Setting
</Button>
</div>
) : (
<Button
type="button"
onClick={handleCreatePermissionSetting}
>
<Plus className="h-4 w-4" /> Create Permission Setting
</Button>
);
};
export default MarkdownPermissionSettingPanel;

View File

@@ -0,0 +1,106 @@
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 { Plus, Save } from "lucide-react";
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries";
import { Button } from "../../ui/button";
import { Label } from "../../ui/input";
import { Spinner } from "../../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
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 (
<div className="flex justify-center py-6">
<Spinner label="Loading template" />
</div>
);
}
return setting ? (
<div className="mt-4 space-y-4 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Template Setting
</h4>
<div className="space-y-2">
<Label htmlFor="use-template-select">Use Template</Label>
<select
id="use-template-select"
className={SELECT_CLASS}
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>
<Button
type="button"
onClick={handleSaveMarkdownTemplateSetting}
>
<Save className="h-4 w-4" /> Save Template Setting
</Button>
</div>
) : (
<Button
type="button"
onClick={handleCreateTemplateSetting}
>
<Plus className="h-4 w-4" /> Create Template Setting
</Button>
);
};
export default MarkdownTemplateSettingPanel;

View File

@@ -0,0 +1,412 @@
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";
import { Plus, Save, Pencil, Trash2, Check } from "lucide-react";
import { Button } from "../../ui/button";
import { Input, Label } from "../../ui/input";
import { Spinner } from "../../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
const CheckboxRow = ({ checked, onChange, label }) => (
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
className="h-4 w-4 accent-primary"
checked={checked}
onChange={onChange}
/>
{label}
</label>
);
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="mt-4 space-y-5 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Webhook Setting
</h4>
<div className="space-y-2">
<Label>Select or Create a Webhook</Label>
<div className="flex items-center gap-2">
<div className="flex-1">
{isWebhooksLoading ? (
<Spinner label="Loading webhooks" />
) : (
<select
className={SELECT_CLASS}
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>
<Button
type="button"
onClick={handleCreateWebhook}
>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{setting?.webhook_id && (
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUpdateWebhook}
>
<Pencil className="h-4 w-4" /> Update Webhook URL
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleDeleteWebhook}
>
<Trash2 className="h-4 w-4" /> Delete Webhook
</Button>
</div>
)}
</div>
<CheckboxRow
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
label="Enabled"
/>
<div className="space-y-2">
<Label>On Events</Label>
<div className="grid grid-cols-1 gap-3 rounded-md border border-border bg-background/40 p-4 sm:grid-cols-2">
<div className="space-y-2">
<CheckboxRow
checked={isOnMarkdownCreated}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked)
}
label="Markdown Created"
/>
<CheckboxRow
checked={isOnMarkdownUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked)
}
label="Markdown Updated"
/>
<CheckboxRow
checked={isOnMarkdownDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked)
}
label="Markdown Deleted"
/>
</div>
<div className="space-y-2">
<CheckboxRow
checked={isOnPathCreated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_CREATED", e.target.checked)
}
label="Path Created"
/>
<CheckboxRow
checked={isOnPathUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
}
label="Path Updated"
/>
<CheckboxRow
checked={isOnPathDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
}
label="Path Deleted"
/>
</div>
</div>
</div>
<CheckboxRow
checked={isRecursive}
onChange={(e) => setIsRecursive(e.target.checked)}
label="Recursive"
/>
<div className="space-y-2">
<Label>Additional Headers</Label>
<div className="space-y-3 rounded-md border border-border bg-background/40 p-4">
{headerList.map((h, idx) => (
<div className="flex gap-2" key={idx}>
<Input
type="text"
placeholder="key"
value={h.key}
onChange={(e) => handleHeaderChange(idx, "key", e.target.value)}
/>
<Input
type="text"
placeholder="value"
value={h.value}
onChange={(e) =>
handleHeaderChange(idx, "value", e.target.value)
}
/>
</div>
))}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddHeader}
>
<Plus className="h-4 w-4" /> Header
</Button>
<Button
type="button"
size="sm"
onClick={handleApplyHeaders}
>
<Check className="h-4 w-4" /> Apply
</Button>
</div>
</div>
</div>
<Button
type="button"
onClick={handleSaveWebhookSetting}
>
<Save className="h-4 w-4" /> Save Webhook Setting
</Button>
</div>
) : (
<Button
type="button"
onClick={handleCreateWebhookSetting}
>
<Plus className="h-4 w-4" /> Create Webhook Setting
</Button>
);
}
export default WebhookSettingPanel

View File

@@ -0,0 +1,48 @@
import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:shadow-glow hover:brightness-110",
secondary:
"bg-secondary text-secondary-foreground hover:shadow-glow-violet hover:brightness-110",
outline:
"border border-border bg-transparent text-foreground hover:border-primary/60 hover:text-primary",
ghost: "text-muted-foreground hover:bg-accent hover:text-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:brightness-110",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6",
icon: "h-9 w-9",
"icon-sm": "h-7 w-7",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

49
src/components/ui/card.js Normal file
View File

@@ -0,0 +1,49 @@
import React from "react";
import { cn } from "../../lib/utils";
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-lg font-semibold tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-5 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };

View File

@@ -0,0 +1,92 @@
import React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm animate-overlay-in",
className
)}
{...props}
/>
));
DialogOverlay.displayName = "DialogOverlay";
const DialogContent = React.forwardRef(
({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4",
"rounded-lg border border-border bg-card p-6 shadow-glow animate-fade-in",
"max-h-[90vh] overflow-y-auto",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:text-foreground focus:outline-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
);
DialogContent.displayName = "DialogContent";
const DialogHeader = ({ className, ...props }) => (
<div className={cn("flex flex-col gap-1.5", className)} {...props} />
);
const DialogFooter = ({ className, ...props }) => (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"font-mono text-lg font-semibold tracking-tight text-foreground",
className
)}
{...props}
/>
));
DialogTitle.displayName = "DialogTitle";
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = "DialogDescription";
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,72 @@
import React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "../../lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuContent = React.forwardRef(
({ className, sideOffset = 6, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-glow animate-fade-in",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
DropdownMenuContent.displayName = "DropdownMenuContent";
const DropdownMenuItem = React.forwardRef(
({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
"focus:bg-accent focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:size-4 [&_svg]:text-muted-foreground focus:[&_svg]:text-primary",
inset && "pl-8",
className
)}
{...props}
/>
)
);
DropdownMenuItem.displayName = "DropdownMenuItem";
const DropdownMenuLabel = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = "DropdownMenuLabel";
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
};

View File

@@ -0,0 +1,45 @@
import React from "react";
import { cn } from "../../lib/utils";
const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (
<input
ref={ref}
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
));
Input.displayName = "Input";
const Textarea = React.forwardRef(({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground transition-colors",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
));
Textarea.displayName = "Textarea";
const Label = React.forwardRef(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-xs font-medium uppercase tracking-wide text-muted-foreground",
className
)}
{...props}
/>
));
Label.displayName = "Label";
export { Input, Textarea, Label };

88
src/components/ui/misc.js Normal file
View File

@@ -0,0 +1,88 @@
import React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { Loader2 } from "lucide-react";
import { cn } from "../../lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef(
({ className, sideOffset = 6, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-foreground shadow-glow animate-fade-in",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
)
);
TooltipContent.displayName = "TooltipContent";
const Separator = React.forwardRef(
({ className, orientation = "horizontal", ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className
)}
{...props}
/>
)
);
Separator.displayName = "Separator";
function Badge({ className, variant = "default", ...props }) {
const variants = {
default: "border-primary/30 bg-primary/10 text-primary",
violet: "border-secondary/30 bg-secondary/10 text-secondary",
muted: "border-border bg-muted text-muted-foreground",
destructive:
"border-destructive/30 bg-destructive/10 text-destructive",
};
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider",
variants[variant],
className
)}
{...props}
/>
);
}
function Spinner({ className, label = "Loading" }) {
return (
<div
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground",
className
)}
>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="font-mono text-xs uppercase tracking-wide">
{label}
</span>
</div>
);
}
export {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipContent,
Separator,
Badge,
Spinner,
};

View File

@@ -0,0 +1,25 @@
import React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.Scrollbar
orientation="vertical"
className="flex w-2 touch-none select-none p-0.5 transition-colors"
>
<ScrollAreaPrimitive.Thumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.Scrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = "ScrollArea";
export { ScrollArea };

43
src/components/ui/tabs.js Normal file
View File

@@ -0,0 +1,43 @@
import React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex items-center gap-1 rounded-md border border-border bg-surface/60 p-1",
className
)}
{...props}
/>
));
TabsList.displayName = "TabsList";
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center gap-1.5 rounded-sm px-3 py-1.5 font-mono text-xs font-medium uppercase tracking-wide transition-all",
"text-muted-foreground hover:text-foreground",
"data-[state=active]:bg-primary/15 data-[state=active]:text-primary data-[state=active]:shadow-[0_0_12px_-4px_hsl(var(--primary)/0.6)]",
"focus-visible:outline-none",
className
)}
{...props}
/>
));
TabsTrigger.displayName = "TabsTrigger";
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("focus-visible:outline-none", className)}
{...props}
/>
));
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,10 +0,0 @@
const config = {
BACKEND_HOST: null,
FRONTEND_HOST: null,
KC_CLIENT_ID: null,
OIDC_CONFIG: {},
TEST_PASS1: null,
TEST_PASS2: null
}
export default config;

104
src/globals.css Normal file
View File

@@ -0,0 +1,104 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Dark-tech design tokens (single dark theme). HSL triplets so Tailwind
color utilities and shadcn-style primitives resolve via hsl(var(--x)). */
:root,
.dark {
--background: 222 32% 6%;
--foreground: 210 22% 92%;
--surface: 222 28% 8.5%;
--card: 222 26% 9.5%;
--card-foreground: 210 22% 92%;
--muted: 222 20% 14%;
--muted-foreground: 215 16% 60%;
--accent: 222 24% 13%;
--accent-foreground: 210 22% 92%;
--border: 220 18% 18%;
--input: 220 18% 16%;
--ring: 186 92% 56%;
/* cyan primary, violet secondary — the neon accents */
--primary: 186 92% 56%;
--primary-foreground: 222 47% 6%;
--secondary: 258 90% 68%;
--secondary-foreground: 0 0% 100%;
--destructive: 0 72% 56%;
--destructive-foreground: 0 0% 100%;
--radius: 0.5rem;
}
* {
border-color: hsl(var(--border));
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Ambient grid + glow backdrop for the app shell */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(60rem 60rem at 110% -10%, hsl(258 90% 68% / 0.10), transparent 60%),
radial-gradient(50rem 50rem at -10% 110%, hsl(186 92% 56% / 0.08), transparent 60%);
}
::selection {
background: hsl(var(--primary) / 0.3);
color: hsl(var(--foreground));
}
/* Slim techy scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
*::-webkit-scrollbar {
width: 9px;
height: 9px;
}
*::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 999px;
}
*::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
@layer utilities {
.neon-text {
color: hsl(var(--primary));
text-shadow: 0 0 12px hsl(var(--primary) / 0.55);
}
.neon-text-violet {
color: hsl(var(--secondary));
text-shadow: 0 0 12px hsl(var(--secondary) / 0.55);
}
.glass {
background: hsl(var(--surface) / 0.7);
backdrop-filter: blur(10px);
}
}

View File

@@ -1,13 +1,62 @@
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 "./globals.css";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"
import ConfigProvider from "./ConfigProvider";
import ControlledReactQueryDevtools from "./components/Debug/ControlledReactQueryDevtools";
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>
);

7
src/lib/utils.js Normal file
View File

@@ -0,0 +1,7 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional + Tailwind classes (shadcn convention). */
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

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;

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,61 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetch_ } from "../request-utils";
import { useConfig } from "../../ConfigProvider";
export const usePatches = (markdownId) => {
const config = useConfig();
return useQuery({
queryKey: ["patches", markdownId],
queryFn: () =>
fetch_(`${config.BACKEND_HOST}/api/patch/by_markdown/${markdownId}`),
enabled: !!markdownId,
});
};
export const useCreatePatch = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useMutation({
mutationFn: (data) =>
fetch_(`${config.BACKEND_HOST}/api/patch/`, {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: (res) => {
queryClient.invalidateQueries({
queryKey: ["patches", res.markdown_id],
});
},
});
};
export const useUpdatePatch = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useMutation({
mutationFn: ({ id, data }) =>
fetch_(`${config.BACKEND_HOST}/api/patch/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
onSuccess: (res) => {
queryClient.invalidateQueries({
queryKey: ["patches", res.markdown_id],
});
},
});
};
export const useDeletePatch = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useMutation({
mutationFn: ({ id }) =>
fetch_(`${config.BACKEND_HOST}/api/patch/${id}`, {
method: "DELETE",
}),
onSuccess: (_res, { markdownId }) => {
queryClient.invalidateQueries({ queryKey: ["patches", markdownId] });
},
});
};

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

@@ -1,95 +0,0 @@
import {data} from "react-router-dom";
const ongoingRequests = new Map();
function _default_hash_function(url, method, body){
const url_obj = new URL(url, window.location.origin);
const query_params = [...url_obj.searchParams.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join("&");
const normalized_url = `${url_obj.origin}${url_obj.pathname}${query_params ? "?" + query_params : ""}`;
const normalized_body = body
? JSON.stringify(
Object.keys(body)
.sort()
.reduce((acc, key) => {
acc[key] = body[key];
return acc;
}, {})
)
: "";
return `${method.toUpperCase()}:${normalized_url}:${normalized_body}`;
}
export async function fetch_(url, init = {}, init_options = {}){
const default_options = {
use_cache: true,
cache_key: _default_hash_function(url, init.method || "GET", init.body || null),
cache_expires: 60,
use_token: true,
};
const options = { ...default_options, ...init_options };
const token = options.use_token ? localStorage.getItem("accessToken") : null;
const request_options = {
...init,
headers: {
...(init.headers || {}),
...(token ? {Authorization: `Bearer ${token}`} : {}),
...(init.method && ['PUT', 'POST'].includes(init.method.toUpperCase())
? {'Content-Type': 'application/json'}
: {}),
}
};
if(options.use_cache && ongoingRequests.has(options.cache_key)){
return ongoingRequests.get(options.cache_key);
}
const now = Date.now();
const cached_data = localStorage.getItem(options.cache_key);
if(options.use_cache && cached_data){
const {data, timestamp} = JSON.parse(cached_data);
if(now - timestamp < options.cache_expires * 1000)
return data;
}
try {
const fetchPromise = fetch(url, request_options)
.then((response) => {
if(!response.ok) {
throw new Error(`RESPONSE_ERROR: ${response.status}`);
}
return response.json();
})
.then((data) => {
if (options.use_cache)
{
localStorage.setItem(
options.cache_key,
JSON.stringify({ data, timestamp: now })
);
ongoingRequests.delete(options.cache_key);
}
return data;
});
if(options.use_cache){
ongoingRequests.set(options.cache_key, fetchPromise);
}
return await fetchPromise;
}catch(error){
if(options.use_cache){
ongoingRequests.delete(options.cache_key);
}
throw error;
}
}

11
src/utils/safe-json.js Normal file
View File

@@ -0,0 +1,11 @@
// Markdown `content` is a JSON string. Records created via the API (or
// legacy/corrupt data) may not be valid JSON; an unguarded JSON.parse in a
// render path throws and white-screens the whole page (including the public
// /pg/* route). Parse defensively and degrade to a readable fallback.
export function parseMarkdownContent(raw) {
try {
return JSON.parse(raw);
} catch (e) {
return { markdown: "> ⚠️ This document could not be displayed: its stored content is not valid." };
}
}

74
tailwind.config.js Normal file
View File

@@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./src/**/*.{js,jsx}", "./public/index.html"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
surface: "hsl(var(--surface))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
mono: [
"JetBrains Mono",
"Fira Code",
"ui-monospace",
"SFMono-Regular",
"Menlo",
"monospace",
],
},
boxShadow: {
glow: "0 0 0 1px hsl(var(--primary) / 0.25), 0 0 24px -6px hsl(var(--primary) / 0.45)",
"glow-violet":
"0 0 0 1px hsl(var(--secondary) / 0.25), 0 0 24px -6px hsl(var(--secondary) / 0.45)",
},
keyframes: {
"fade-in": {
from: { opacity: 0, transform: "translateY(4px)" },
to: { opacity: 1, transform: "translateY(0)" },
},
"overlay-in": { from: { opacity: 0 }, to: { opacity: 1 } },
},
animation: {
"fade-in": "fade-in 0.18s ease-out",
"overlay-in": "overlay-in 0.15s ease-out",
},
},
},
plugins: [],
};

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,15 @@
//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',
// Content-hashed filename: index.html (injected by HtmlWebpackPlugin,
// and not edge-cached) points at a unique URL each build, so a new
// deploy is picked up immediately — no stale CDN/browser bundle.
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js',
publicPath: '/',
clean: true,
},
@@ -21,14 +24,19 @@ module.exports = {
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
use: ['style-loader', 'css-loader', 'postcss-loader'],
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: "./public/index.html",
inject: true
}),
new webpack.ProvidePlugin({
process: 'process/browser.js'
})
],
devServer: {
static: path.join(__dirname, 'public'),
@@ -36,5 +44,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',
};