Compare commits
57 Commits
ccdded32a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 47606ac0a8 | |||
| 16a0861f77 | |||
| f4787d0a27 | |||
| 40f051bc77 | |||
| 9f2faed91f | |||
| a98badf653 | |||
| f9d618d0dd | |||
| cbac2b0771 | |||
| 117a1385ab | |||
| 3ec528701e | |||
| ba08bba7de | |||
| e91bea280b | |||
| 952387d50f | |||
| 045c7c51d6 | |||
| c9310250e4 | |||
| a08164e914 | |||
| 30a46d5064 | |||
| e5affe3465 | |||
| 101666d26d | |||
| 87b4246a9b | |||
| 1ce2eebbfa | |||
| 9ea44385ee | |||
| c20cb168ff | |||
| 137ea649f8 | |||
| 947b59e3ea | |||
| 09338a2683 | |||
| dc0ff3b406 | |||
| 2c330904e4 | |||
| 8abf54eade | |||
| dd1ee9fd5c | |||
| 2911f8722e | |||
| 39a69ca5b8 | |||
| 76b298ac8b | |||
| 3f4669f776 | |||
| cdf9039049 | |||
| 74326f60c3 | |||
| cfcf823e4c | |||
| ed13196ef8 | |||
| 2837edef31 | |||
| 75d083f11f | |||
| 34ab63d0bf | |||
| a9d9b4e8f0 | |||
| 76b64716c2 | |||
| d88fb34881 | |||
| 90897165db | |||
| 379843404f | |||
| ba2274c76e | |||
| 3f6461d17e | |||
| 931ade90a3 | |||
| d8da574833 | |||
| ba69541a7b | |||
| 0e6fd8409a | |||
| a31cec7ef0 | |||
| 7eaf37223c | |||
| 20f205ba59 | |||
| a1473e51e7 | |||
| df7ba4c490 |
3
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
summerizer.py
|
summerizer.py
|
||||||
|
node_modules
|
||||||
|
|||||||
@@ -1,36 +1,37 @@
|
|||||||
#!/bin/bash
|
#!/bin/sh
|
||||||
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
|
|
||||||
|
|
||||||
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}\",
|
cat <<EOL > /usr/share/nginx/html/config.json
|
||||||
FRONTEND_HOST: \"${FRONTEND_HOST}\",
|
{
|
||||||
KC_CLIENT_ID: \"${KC_CLIENT_ID}\",
|
"BACKEND_HOST": "${BACKEND_HOST}",
|
||||||
OIDC_CONFIG: {
|
"FRONTEND_HOST": "${FRONTEND_HOST}",
|
||||||
authority: \"${KC_HOST}/realms/${KC_REALM}\",
|
"KC_CLIENT_ID": "${KC_CLIENT_ID}",
|
||||||
client_id: \"${KC_CLIENT_ID}\",
|
"OIDC_CONFIG": {
|
||||||
redirect_uri: \"${FRONTEND_HOST}/callback\",
|
"authority": "${KC_HOST}/realms/${KC_REALM}",
|
||||||
post_logout_redirect_uri: \"${FRONTEND_HOST}\",
|
"client_id": "${KC_CLIENT_ID}",
|
||||||
response_type: \"code\",
|
"redirect_uri": "${FRONTEND_HOST}/callback",
|
||||||
scope: \"openid profile email roles\",
|
"post_logout_redirect_uri": "${FRONTEND_HOST}",
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": "openid profile email roles",
|
||||||
|
"popup_redirect_uri": "${FRONTEND_HOST}/popup_callback",
|
||||||
|
"silent_redirect_uri": "${FRONTEND_HOST}/silent_callback"
|
||||||
},
|
},
|
||||||
};
|
"DEBUG": ${DEBUG}
|
||||||
export default config;
|
}
|
||||||
" > /app/src/config.js;
|
EOL
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
23
Dockerfile
@@ -1,15 +1,28 @@
|
|||||||
FROM node:18 as build-stage
|
FROM node:20-alpine AS build-stage
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY BuildConfig.sh /app/BuildConfig.sh
|
|
||||||
RUN chmod +x /app/BuildConfig.sh
|
|
||||||
RUN npm run build
|
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 --from=build-stage /app/public/fonts /usr/share/nginx/html/fonts
|
||||||
|
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
42
package.json
@@ -12,28 +12,66 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"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",
|
"bulma": "^1.0.2",
|
||||||
"katex": "^0.16.11",
|
"katex": "^0.16.11",
|
||||||
"oidc-client-ts": "^3.1.0",
|
"oidc-client-ts": "^3.1.0",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^7.0.1",
|
"react-router-dom": "^7.0.1",
|
||||||
"react-syntax-highlighter": "^15.6.1",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"redux": "^5.0.1",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.0",
|
"@babel/core": "^7.26.0",
|
||||||
"@babel/preset-env": "^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",
|
"babel-loader": "^9.2.1",
|
||||||
"css-loader": "^7.1.2",
|
"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",
|
"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": "^1.81.0",
|
||||||
"sass-loader": "^16.0.3",
|
"sass-loader": "^16.0.3",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
"webpack": "^5.96.1",
|
"webpack": "^5.96.1",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"webpack-dev-server": "^5.1.0"
|
"webpack-dev-server": "^5.1.0"
|
||||||
|
|||||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
public/fonts/-F63fjptAgt5VM-kVkqdyU8n1i8q1w.woff2
Normal file
BIN
public/fonts/-F63fjptAgt5VM-kVkqdyU8n1iAq129k.woff2
Normal file
BIN
public/fonts/-F63fjptAgt5VM-kVkqdyU8n1iEq129k.woff2
Normal file
BIN
public/fonts/-F63fjptAgt5VM-kVkqdyU8n1iIq129k.woff2
Normal file
BIN
public/fonts/-F63fjptAgt5VM-kVkqdyU8n1isq129k.woff2
Normal file
BIN
public/fonts/-F6pfjptAgt5VM-kVkqdyU8n1ioa0Xdgv-s.woff2
Normal file
BIN
public/fonts/-F6pfjptAgt5VM-kVkqdyU8n1ioa1Xdg.woff2
Normal file
BIN
public/fonts/-F6pfjptAgt5VM-kVkqdyU8n1ioa23dgv-s.woff2
Normal file
BIN
public/fonts/-F6pfjptAgt5VM-kVkqdyU8n1ioa2Hdgv-s.woff2
Normal file
BIN
public/fonts/-F6pfjptAgt5VM-kVkqdyU8n1ioa2ndgv-s.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3twJwl1FgtIU.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3twJwl5FgtIU.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3twJwl9FgtIU.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3twJwlBFgg.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3twJwlRFgtIU.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3vAOwl1FgtIU.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3vAOwl5FgtIU.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3vAOwl9FgtIU.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3vAOwlBFgg.woff2
Normal file
BIN
public/fonts/-F6qfjptAgt5VM-kVkqdyU8n3vAOwlRFgtIU.woff2
Normal file
BIN
public/fonts/RWmVoLyb5fEqtsfBX9PDZIGr2tFubRh7A3eREnc.woff2
Normal file
BIN
public/fonts/RWmVoLyb5fEqtsfBX9PDZIGr2tFubRh7AneREnc.woff2
Normal file
BIN
public/fonts/RWmVoLyb5fEqtsfBX9PDZIGr2tFubRh7DXeR.woff2
Normal file
207
public/fonts/fonts.css
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6pfjptAgt5VM-kVkqdyU8n1ioa2Hdgv-s.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6pfjptAgt5VM-kVkqdyU8n1ioa0Xdgv-s.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6pfjptAgt5VM-kVkqdyU8n1ioa2ndgv-s.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6pfjptAgt5VM-kVkqdyU8n1ioa23dgv-s.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6pfjptAgt5VM-kVkqdyU8n1ioa1Xdg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F63fjptAgt5VM-kVkqdyU8n1iIq129k.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F63fjptAgt5VM-kVkqdyU8n1isq129k.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F63fjptAgt5VM-kVkqdyU8n1iAq129k.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F63fjptAgt5VM-kVkqdyU8n1iEq129k.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F63fjptAgt5VM-kVkqdyU8n1i8q1w.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3twJwl1FgtIU.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3twJwlRFgtIU.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3twJwl9FgtIU.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3twJwl5FgtIU.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3twJwlBFgg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3vAOwl1FgtIU.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3vAOwlRFgtIU.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3vAOwl9FgtIU.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3vAOwl5FgtIU.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./-F6qfjptAgt5VM-kVkqdyU8n3vAOwlBFgg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Major Mono Display';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./RWmVoLyb5fEqtsfBX9PDZIGr2tFubRh7AneREnc.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Major Mono Display';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./RWmVoLyb5fEqtsfBX9PDZIGr2tFubRh7A3eREnc.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Major Mono Display';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(./RWmVoLyb5fEqtsfBX9PDZIGr2tFubRh7DXeR.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
BIN
public/icons/email.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/icons/git.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
44
public/icons/hangman-lab-favicon.svg
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 733.000000 733.000000" role="img" aria-label="Hangman Lab">
|
||||||
|
<g transform="translate(0.000000,733.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#6a8018" stroke="none">
|
||||||
|
<path d="M812 6860 c-66 -40 -73 -66 -70 -242 3 -141 5 -159 24 -185 43 -58
|
||||||
|
63 -63 262 -64 l182 -1 0 -2694 0 -2694 -290 0 c-314 0 -332 -3 -380 -54 -24
|
||||||
|
-27 -25 -32 -28 -199 -3 -195 4 -218 71 -250 32 -16 257 -17 3084 -17 2955 0
|
||||||
|
3049 1 3083 19 65 34 75 66 75 236 0 186 -16 224 -104 254 -25 8 -682 11
|
||||||
|
-2512 11 l-2479 0 0 1998 0 1997 698 697 697 697 670 -3 c369 -2 680 -4 693
|
||||||
|
-5 22 -1 22 -1 22 -269 l0 -268 84 -12 c47 -7 103 -19 125 -27 41 -14 41 -14
|
||||||
|
43 283 l3 297 631 1 c698 2 673 -1 714 67 18 28 20 51 20 187 0 173 -8 201
|
||||||
|
-72 240 -33 20 -56 20 -2623 20 -2567 0 -2590 0 -2623 -20z m1725 -500 c-1 -3
|
||||||
|
-183 -185 -404 -404 l-403 -399 0 405 0 406 405 -1 c223 -1 404 -4 402 -7z
|
||||||
|
M4283 5701 c-459 -125 -690 -624 -483 -1046 67 -138 196 -266 335 -335 281
|
||||||
|
-139 612 -93 837 118 235 219 297 575 152 870 -85 174 -235 306 -427 375 -111
|
||||||
|
40 -302 48 -414 18z m267 -211 c95 -15 201 -70 274 -144 273 -274 173 -723
|
||||||
|
-189 -851 -93 -33 -236 -35 -326 -5 -202 68 -340 249 -356 465 -18 242 153
|
||||||
|
471 394 529 71 18 119 19 203 6z M5945 4716 c-56 -25 -80 -61 -80 -122 0 -48
|
||||||
|
4 -57 36 -90 88 -88 219 -29 219 98 0 45 -34 96 -75 114 -42 17 -61 17 -100 0z
|
||||||
|
M5675 4466 c-60 -26 -73 -109 -26 -157 62 -61 161 -19 161 70 0 74 -68 118
|
||||||
|
-135 87z M3747 4397 c-10 -7 -226 -223 -479 -482 -307 -313 -467 -483 -479
|
||||||
|
-510 -35 -75 -17 -177 38 -227 39 -35 114 -61 160 -54 100 13 104 17 583 554
|
||||||
|
l145 162 3 -404 c3 -455 15 -342 -137 -1186 -76 -423 -101 -587 -101 -664 0
|
||||||
|
-190 182 -307 377 -242 62 21 136 95 152 152 6 22 29 188 52 369 57 466 109
|
||||||
|
839 118 848 4 4 52 6 106 5 l99 -3 67 -210 c38 -115 68 -220 69 -232 0 -12
|
||||||
|
-38 -152 -85 -311 -47 -159 -85 -302 -85 -317 0 -128 109 -225 255 -225 109 1
|
||||||
|
185 42 228 125 26 51 237 683 237 710 0 11 -54 182 -121 380 -121 360 -121
|
||||||
|
360 -134 605 -8 135 -14 292 -14 350 l0 105 152 -158 c84 -87 166 -167 182
|
||||||
|
-177 44 -29 98 -26 135 8 38 35 187 379 173 401 -5 8 -34 20 -64 26 -59 11
|
||||||
|
-38 -8 -393 362 -89 93 -100 97 -174 59 -59 -30 -161 -62 -229 -71 -52 -7 -53
|
||||||
|
-7 -53 -41 0 -44 -21 -84 -61 -118 -38 -32 -41 0 32 -455 l52 -325 -82 -78
|
||||||
|
c-45 -43 -85 -78 -89 -78 -4 0 -42 35 -84 78 l-77 77 34 215 c19 118 46 289
|
||||||
|
61 379 26 164 26 164 -5 188 -42 33 -54 58 -61 121 -5 54 -5 54 -92 87 -98 38
|
||||||
|
-184 89 -243 145 -76 70 -123 87 -168 57z M5504 4170 c-74 -30 -69 -170 6
|
||||||
|
-170 19 0 20 -7 20 -123 0 -122 0 -122 -54 -262 -30 -77 -97 -243 -150 -369
|
||||||
|
-106 -252 -113 -287 -73 -347 46 -70 38 -69 560 -69 327 0 475 3 490 11 69 35
|
||||||
|
107 109 88 172 -5 17 -62 142 -126 277 -225 470 -215 443 -215 586 0 114 2
|
||||||
|
124 18 124 57 0 72 101 23 151 -29 29 -29 29 -298 28 -147 0 -278 -4 -289 -9z
|
||||||
|
m376 -307 c1 -148 1 -148 81 -333 101 -231 98 -222 68 -216 -13 3 -116 22
|
||||||
|
-229 42 -112 19 -208 37 -212 40 -4 2 18 75 48 160 54 156 54 156 54 305 l0
|
||||||
|
149 95 0 95 0 0 -147z m-219 -593 c22 0 49 -42 49 -75 0 -46 -29 -75 -74 -75
|
||||||
|
-37 0 -76 36 -76 71 0 46 45 94 78 83 8 -2 18 -4 23 -4z m327 -126 c53 -59 -7
|
||||||
|
-144 -80 -113 -54 22 -65 83 -22 125 30 31 67 27 102 -12z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
44
public/icons/hangman-lab-logo.svg
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 733.000000 733.000000" role="img" aria-label="Hangman Lab">
|
||||||
|
<g transform="translate(0.000000,733.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#d8ff3e" stroke="none">
|
||||||
|
<path d="M812 6860 c-66 -40 -73 -66 -70 -242 3 -141 5 -159 24 -185 43 -58
|
||||||
|
63 -63 262 -64 l182 -1 0 -2694 0 -2694 -290 0 c-314 0 -332 -3 -380 -54 -24
|
||||||
|
-27 -25 -32 -28 -199 -3 -195 4 -218 71 -250 32 -16 257 -17 3084 -17 2955 0
|
||||||
|
3049 1 3083 19 65 34 75 66 75 236 0 186 -16 224 -104 254 -25 8 -682 11
|
||||||
|
-2512 11 l-2479 0 0 1998 0 1997 698 697 697 697 670 -3 c369 -2 680 -4 693
|
||||||
|
-5 22 -1 22 -1 22 -269 l0 -268 84 -12 c47 -7 103 -19 125 -27 41 -14 41 -14
|
||||||
|
43 283 l3 297 631 1 c698 2 673 -1 714 67 18 28 20 51 20 187 0 173 -8 201
|
||||||
|
-72 240 -33 20 -56 20 -2623 20 -2567 0 -2590 0 -2623 -20z m1725 -500 c-1 -3
|
||||||
|
-183 -185 -404 -404 l-403 -399 0 405 0 406 405 -1 c223 -1 404 -4 402 -7z
|
||||||
|
M4283 5701 c-459 -125 -690 -624 -483 -1046 67 -138 196 -266 335 -335 281
|
||||||
|
-139 612 -93 837 118 235 219 297 575 152 870 -85 174 -235 306 -427 375 -111
|
||||||
|
40 -302 48 -414 18z m267 -211 c95 -15 201 -70 274 -144 273 -274 173 -723
|
||||||
|
-189 -851 -93 -33 -236 -35 -326 -5 -202 68 -340 249 -356 465 -18 242 153
|
||||||
|
471 394 529 71 18 119 19 203 6z M5945 4716 c-56 -25 -80 -61 -80 -122 0 -48
|
||||||
|
4 -57 36 -90 88 -88 219 -29 219 98 0 45 -34 96 -75 114 -42 17 -61 17 -100 0z
|
||||||
|
M5675 4466 c-60 -26 -73 -109 -26 -157 62 -61 161 -19 161 70 0 74 -68 118
|
||||||
|
-135 87z M3747 4397 c-10 -7 -226 -223 -479 -482 -307 -313 -467 -483 -479
|
||||||
|
-510 -35 -75 -17 -177 38 -227 39 -35 114 -61 160 -54 100 13 104 17 583 554
|
||||||
|
l145 162 3 -404 c3 -455 15 -342 -137 -1186 -76 -423 -101 -587 -101 -664 0
|
||||||
|
-190 182 -307 377 -242 62 21 136 95 152 152 6 22 29 188 52 369 57 466 109
|
||||||
|
839 118 848 4 4 52 6 106 5 l99 -3 67 -210 c38 -115 68 -220 69 -232 0 -12
|
||||||
|
-38 -152 -85 -311 -47 -159 -85 -302 -85 -317 0 -128 109 -225 255 -225 109 1
|
||||||
|
185 42 228 125 26 51 237 683 237 710 0 11 -54 182 -121 380 -121 360 -121
|
||||||
|
360 -134 605 -8 135 -14 292 -14 350 l0 105 152 -158 c84 -87 166 -167 182
|
||||||
|
-177 44 -29 98 -26 135 8 38 35 187 379 173 401 -5 8 -34 20 -64 26 -59 11
|
||||||
|
-38 -8 -393 362 -89 93 -100 97 -174 59 -59 -30 -161 -62 -229 -71 -52 -7 -53
|
||||||
|
-7 -53 -41 0 -44 -21 -84 -61 -118 -38 -32 -41 0 32 -455 l52 -325 -82 -78
|
||||||
|
c-45 -43 -85 -78 -89 -78 -4 0 -42 35 -84 78 l-77 77 34 215 c19 118 46 289
|
||||||
|
61 379 26 164 26 164 -5 188 -42 33 -54 58 -61 121 -5 54 -5 54 -92 87 -98 38
|
||||||
|
-184 89 -243 145 -76 70 -123 87 -168 57z M5504 4170 c-74 -30 -69 -170 6
|
||||||
|
-170 19 0 20 -7 20 -123 0 -122 0 -122 -54 -262 -30 -77 -97 -243 -150 -369
|
||||||
|
-106 -252 -113 -287 -73 -347 46 -70 38 -69 560 -69 327 0 475 3 490 11 69 35
|
||||||
|
107 109 88 172 -5 17 -62 142 -126 277 -225 470 -215 443 -215 586 0 114 2
|
||||||
|
124 18 124 57 0 72 101 23 151 -29 29 -29 29 -298 28 -147 0 -278 -4 -289 -9z
|
||||||
|
m376 -307 c1 -148 1 -148 81 -333 101 -231 98 -222 68 -216 -13 3 -116 22
|
||||||
|
-229 42 -112 19 -208 37 -212 40 -4 2 18 75 48 160 54 156 54 156 54 305 l0
|
||||||
|
149 95 0 95 0 0 -147z m-219 -593 c22 0 49 -42 49 -75 0 -46 -29 -75 -74 -75
|
||||||
|
-37 0 -76 36 -76 71 0 46 45 94 78 83 8 -2 18 -4 23 -4z m327 -126 c53 -59 -7
|
||||||
|
-144 -80 -113 -54 22 -65 83 -22 125 30 31 67 27 102 -12z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/icons/linkedin.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/icons/logo.ico
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/icons/logo.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
1027
public/icons/logo32.svg
Normal file
|
After Width: | Height: | Size: 58 KiB |
211484
public/icons/logoxl-var1.svg
Normal file
|
After Width: | Height: | Size: 13 MiB |
@@ -1,11 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
<title>Hangman Lab</title>
|
<title>Hangman Lab</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/hangman-lab-favicon.svg?v=hl-20260517b">
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
18
src/App.css
@@ -1,16 +1,4 @@
|
|||||||
.app-container {
|
/* Layout now lives in App.js via Tailwind; keep body from double-scrolling. */
|
||||||
display: flex;
|
:root {
|
||||||
flex-direction: column;
|
overflow: hidden;
|
||||||
height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-container {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
72
src/App.js
@@ -1,35 +1,59 @@
|
|||||||
import React from "react";
|
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 MainNavigation from "./components/Navigations/MainNavigation";
|
||||||
import SideNavigation from "./components/Navigations/SideNavigation";
|
import SideNavigation from "./components/Navigations/SideNavigation";
|
||||||
import MarkdownContent from "./components/MarkdownContent";
|
import MarkdownContent from "./components/Markdowns/MarkdownContent";
|
||||||
import MarkdownEditor from "./components/Markdowns/MarkdownEditor";
|
import MarkdownEditor from "./components/Markdowns/MarkdownEditor";
|
||||||
|
import StandaloneMarkdownPage from "./components/Markdowns/StandaloneMarkdownPage";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import Callback from "./Callback";
|
import Callback from "./components/KeycloakCallbacks/Callback";
|
||||||
import config from "./config";
|
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 = () => {
|
const App = () => {
|
||||||
console.log(config)
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Provider store={store}>
|
||||||
<div className="app-container">
|
<TooltipProvider delayDuration={200}>
|
||||||
<MainNavigation />
|
<Router>
|
||||||
<div className="content-container">
|
<Routes>
|
||||||
<SideNavigation />
|
<Route path="/pg/*" element={<StandaloneMarkdownPage />} />
|
||||||
<main className="main-content">
|
<Route path="*" element={
|
||||||
<Routes>
|
<div className="relative z-10 flex h-screen flex-col">
|
||||||
<Route path="/" element={<h1>Welcome to My React Project</h1>} />
|
<MainNavigation />
|
||||||
<Route path="/markdown/:id" element={<MarkdownContent />} />
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<Route path="/callback" element={<Callback />} />
|
<SideNavigation />
|
||||||
<Route path="/test" element={<h1>TEST</h1>}></Route>
|
<main className="flex-1 overflow-y-auto">
|
||||||
<Route path="/markdown/create" element={<MarkdownEditor />}></Route>
|
<div className="mx-auto w-full max-w-5xl px-6 py-8 pb-28">
|
||||||
<Route path="/markdown/edit/:id" element={<MarkdownEditor />}></Route>
|
<Routes>
|
||||||
</Routes>
|
<Route
|
||||||
</main>
|
path="/"
|
||||||
</div>
|
element={<Navigate to="/markdown/1" />}
|
||||||
</div>
|
/>
|
||||||
</Router>
|
<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;
|
||||||
|
|||||||
@@ -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 { UserManager } from "oidc-client-ts";
|
||||||
import config from "./config";
|
import { ConfigContext } from "./ConfigProvider";
|
||||||
|
|
||||||
|
|
||||||
export const AuthContext = createContext({
|
export const AuthContext = createContext({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -11,12 +10,20 @@ export const AuthContext = createContext({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const AuthProvider = ({ children }) => {
|
const AuthProvider = ({ children }) => {
|
||||||
|
const { config, isLoading, error } = useContext(ConfigContext);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [roles, setRoles] = useState([]);
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (isLoading || error || !userManager) return;
|
||||||
|
|
||||||
userManager.getUser()
|
userManager.getUser()
|
||||||
.then((user) => {
|
.then((user) => {
|
||||||
if (user && !user.expired) {
|
if (user && !user.expired) {
|
||||||
@@ -30,27 +37,58 @@ const AuthProvider = ({ children }) => {
|
|||||||
.then((newUser) => {
|
.then((newUser) => {
|
||||||
setUser(newUser);
|
setUser(newUser);
|
||||||
localStorage.setItem("accessToken", newUser.access_token);
|
localStorage.setItem("accessToken", newUser.access_token);
|
||||||
const clientRoles =
|
const clientRoles = newUser?.profile?.resource_access?.[config.KC_CLIENT_ID]?.roles || [];
|
||||||
newUser?.profile?.resource_access?.[config.KC_CLIENT_ID]?.roles || [];
|
|
||||||
setRoles(clientRoles);
|
setRoles(clientRoles);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(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 = () => {
|
const login = () => {
|
||||||
userManager
|
if (userManager) {
|
||||||
.signinRedirect()
|
userManager
|
||||||
.catch(
|
.signinRedirect()
|
||||||
(err) => {
|
.catch((err) => {
|
||||||
console.log(config);
|
console.log(config);
|
||||||
console.log(err);
|
console.log(err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const logout = () => userManager.signoutRedirect();
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
if (userManager) {
|
||||||
|
userManager.signoutRedirect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, roles, login, logout }}>
|
<AuthContext.Provider value={{ user, roles, login, logout }}>
|
||||||
@@ -59,4 +97,4 @@ const AuthProvider = ({ children }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AuthProvider;
|
export default AuthProvider;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
14
src/components/Debug/ControlledReactQueryDevtools.js
Normal 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
@@ -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
@@ -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;
|
||||||
24
src/components/KeycloakCallbacks/Callback.js
Normal 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;
|
||||||
29
src/components/KeycloakCallbacks/PopupCallback.js
Normal 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;
|
||||||
26
src/components/KeycloakCallbacks/SilentCallback.js
Normal 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;
|
||||||
@@ -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;
|
|
||||||
57
src/components/MarkdownTemplate/EnumsEditor.js
Normal 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;
|
||||||
18
src/components/MarkdownTemplate/LayoutEditor.js
Normal 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;
|
||||||
107
src/components/MarkdownTemplate/MarkdownTemplateEditor.js
Normal 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;
|
||||||
121
src/components/MarkdownTemplate/ParametersManager.js
Normal 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;
|
||||||
64
src/components/MarkdownTemplate/TemplateSelector.js
Normal 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;
|
||||||
109
src/components/MarkdownTemplate/TypeEditor.js
Normal 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;
|
||||||
1
src/components/Markdowns/MarkdownContent.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/* Styling now lives in the components via Tailwind + MarkdownView.css. */
|
||||||
117
src/components/Markdowns/MarkdownContent.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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, User, Clock, History } from "lucide-react";
|
||||||
|
import { formatDateTime } from "../../lib/utils";
|
||||||
|
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>
|
||||||
|
<div className="-mt-3 mb-6 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<User className="h-3.5 w-3.5 text-secondary" />
|
||||||
|
{markdown.author || "—"}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
created {formatDateTime(markdown.created_at)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<History className="h-3.5 w-3.5" />
|
||||||
|
updated {formatDateTime(markdown.updated_at)}
|
||||||
|
{markdown.last_modified_by
|
||||||
|
? ` by ${markdown.last_modified_by}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/>
|
||||||
|
<PatchCards markdownId={id} />
|
||||||
|
<MarkdownSettingModal
|
||||||
|
isOpen={isSettingModalOpen}
|
||||||
|
markdown={markdown}
|
||||||
|
onClose={() => setSettingModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownContent;
|
||||||
@@ -1,104 +1,34 @@
|
|||||||
|
/* Layout/form styling moved to Tailwind / dark-tech design system in
|
||||||
.markdown-editor-container {
|
MarkdownEditor.js. Only KaTeX sizing and code/pre theming for rendered
|
||||||
max-width: 800px;
|
markdown remain here. */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.katex-display {
|
.katex-display {
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.katex {
|
.katex {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: ui-monospace, "IBM Plex Mono", "Courier New", Courier, monospace;
|
||||||
background-color: #f4f4f4;
|
background-color: hsl(var(--muted));
|
||||||
padding: 2px 4px;
|
color: hsl(var(--foreground));
|
||||||
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
background-color: #2d2d2d;
|
background-color: hsl(var(--surface));
|
||||||
color: #f8f8f2;
|
color: hsl(var(--foreground));
|
||||||
padding: 10px;
|
padding: 12px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.raw-editor {
|
||||||
|
white-space: pre;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,158 +1,337 @@
|
|||||||
import React, { useContext, useEffect, useState } from "react";
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
import { AuthContext } from "../../AuthProvider";
|
import { AuthContext } from "../../AuthProvider";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 "katex/dist/katex.min.css";
|
||||||
import "./MarkdownEditor.css";
|
import "./MarkdownEditor.css";
|
||||||
import config from "../../config";
|
|
||||||
import { fetch_ } from "../../utils/requestUtils";
|
|
||||||
import PathManager from "../PathManager";
|
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 MarkdownEditor = () => {
|
||||||
const { roles } = useContext(AuthContext);
|
const { roles } = useContext(AuthContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { strId } = useParams();
|
||||||
|
const id = Number(strId);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState({});
|
||||||
|
const [shortcut, setShortcut] = useState("");
|
||||||
const [pathId, setPathId] = useState(1);
|
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(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (markdown) {
|
||||||
fetch_(`/api/markdown/${id}`, {}, {
|
setTitle(markdown.title);
|
||||||
use_cache: true,
|
if (markdown.isMessage) {
|
||||||
use_token: false
|
navigate("/");
|
||||||
})
|
alert(markdown.content || "Cannot edit this markdown");
|
||||||
.then((data) => {
|
return;
|
||||||
setTitle(data.title);
|
}
|
||||||
setContent(data.content);
|
try {
|
||||||
setPathId(data.path_id);
|
const parsedContent = JSON.parse(markdown.content);
|
||||||
})
|
setContent(parsedContent);
|
||||||
.catch((err) => {
|
setRawContent(JSON.stringify(parsedContent, null, 2));
|
||||||
console.error("Failed to load markdown", err);
|
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 handleSave = () => {
|
||||||
const url = id ? `${config.BACKEND_HOST}/api/markdown/${id}` : `${config.BACKEND_HOST}/api/markdown/`;
|
if (isRawMode && jsonError) {
|
||||||
const method = id ? "PUT" : "POST";
|
alert("Please fix the JSON errors before saving");
|
||||||
fetch_(url, {
|
return;
|
||||||
method,
|
}
|
||||||
body: JSON.stringify({ title, content, path_id: pathId }),
|
|
||||||
}, {
|
const saveData = {
|
||||||
use_cache: false,
|
title,
|
||||||
use_token: true,
|
content: JSON.stringify(content),
|
||||||
}).then((data) => {
|
path_id: pathId,
|
||||||
if(data.error)
|
shortcut
|
||||||
throw new Error(data.error.message);
|
};
|
||||||
navigate("/");
|
console.log("markdown", markdown);
|
||||||
}).catch((err) => {
|
console.log(markdown?.id ? "update" : "create",)
|
||||||
console.error("Failed to load markdown", err);
|
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");
|
const hasPermission = roles.includes("admin") || roles.includes("creator");
|
||||||
if (!hasPermission) {
|
if (!hasPermission)
|
||||||
return <div className="notification is-danger">Permission Denied</div>;
|
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 (
|
return (
|
||||||
<div className="container mt-5 markdown-editor-container">
|
<div className="markdown-editor-container mx-auto max-w-[90vw] px-6 py-8">
|
||||||
<h2 className="title is-4">{id ? "Edit Markdown" : "Create Markdown"}</h2>
|
<h2 className="mb-6 font-mono text-2xl font-bold tracking-tight text-foreground">
|
||||||
<div className="columns">
|
{id ? "Edit Markdown" : "Create Markdown"}
|
||||||
{/* Editor Column */}
|
</h2>
|
||||||
<div className="column is-half">
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||||
<form>
|
<div>
|
||||||
{/* Title Field */}
|
<form className="space-y-5">
|
||||||
<div className="field">
|
<div className="space-y-2">
|
||||||
<label className="label">Title</label>
|
<Label htmlFor="md-title">Title</Label>
|
||||||
<div className="control">
|
<Input
|
||||||
<input
|
id="md-title"
|
||||||
className="input"
|
type="text"
|
||||||
type="text"
|
placeholder="Enter title"
|
||||||
placeholder="Enter title"
|
value={title}
|
||||||
value={title}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PathManager Field */}
|
<div className="space-y-2">
|
||||||
<div className="field">
|
<Label htmlFor="md-shortcut">Shortcut</Label>
|
||||||
<label className="label">Path</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
|
<PathManager
|
||||||
currentPathId={pathId}
|
currentPathId={pathId}
|
||||||
onPathChange={setPathId}
|
onPathChange={setPathId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Field */}
|
<div>
|
||||||
<div className="field">
|
<TemplateSelector
|
||||||
<label className="label">Content</label>
|
template={selectedTemplate || template}
|
||||||
<div className="control">
|
onChange={handleTemplateChange}
|
||||||
<textarea
|
/>
|
||||||
style={{ height: "70vh" }}
|
|
||||||
className="textarea"
|
|
||||||
placeholder="Enter Markdown content"
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save Button */}
|
<div className="space-y-2">
|
||||||
<div className="field">
|
<div className="flex items-center justify-between">
|
||||||
<div className="control">
|
<Label>Content</Label>
|
||||||
<button
|
<Button
|
||||||
className="button is-primary"
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleEditMode}
|
||||||
>
|
>
|
||||||
Save
|
{isRawMode ? (
|
||||||
</button>
|
<>
|
||||||
|
<LayoutTemplate className="h-4 w-4" /> Switch to Template Editor
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Code className="h-4 w-4" /> Switch to Raw Editor
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Column */}
|
<div>
|
||||||
<div className="column is-half">
|
<h3 className="mb-3 font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
|
||||||
<h3 className="subtitle is-5">Preview</h3>
|
Preview
|
||||||
<div className="content markdown-preview" style={{ height: "70vh" }}>
|
</h3>
|
||||||
<ReactMarkdown
|
<MarkdownView
|
||||||
children={content}
|
content={content}
|
||||||
remarkPlugins={[remarkMath]}
|
template={!markdown?.id ? selectedTemplate : template}
|
||||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
height='70vh'
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MarkdownEditor;
|
export default MarkdownEditor;
|
||||||
|
|||||||
173
src/components/Markdowns/MarkdownView.css
Normal 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: "IBM Plex 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: "IBM Plex 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: "IBM Plex 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: "IBM Plex 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));
|
||||||
|
}
|
||||||
118
src/components/Markdowns/MarkdownView.js
Normal 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;
|
||||||
231
src/components/Markdowns/PatchCards.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Plus, Pencil, Trash2, Save, Layers, User, Clock } from "lucide-react";
|
||||||
|
import { formatDateTime } from "../../lib/utils";
|
||||||
|
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="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-border/40 px-5 py-1.5 font-mono text-[11px] text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<User className="h-3 w-3 text-secondary" />
|
||||||
|
{patch.author || "—"}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatDateTime(patch.created_at)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
edited {formatDateTime(patch.updated_at)}
|
||||||
|
{patch.last_modified_by
|
||||||
|
? ` by ${patch.last_modified_by}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</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;
|
||||||
111
src/components/Markdowns/StandaloneMarkdownPage.js
Normal 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;
|
||||||
179
src/components/Markdowns/TemplatedEditor.js
Normal 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;
|
||||||
191
src/components/Modals/ApiKeyCreationModal.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// Must match the backend allowlist (admin|creator|user).
|
||||||
|
const AVAILABLE_ROLES = ['user', 'creator', 'agent', '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 [alias, setAlias] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [roles, setRoles] = useState(['user']);
|
||||||
|
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 (!alias.trim()) {
|
||||||
|
alert('Alias is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!name.trim()) {
|
||||||
|
alert('API key name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await createApiKeyMutation.mutateAsync({
|
||||||
|
alias: alias.trim(),
|
||||||
|
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.key)
|
||||||
|
.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-alias">Alias</Label>
|
||||||
|
<Input
|
||||||
|
id="api-key-alias"
|
||||||
|
type="text"
|
||||||
|
placeholder="unique alias (reuse to renew)"
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Unique. Using an existing alias renews that
|
||||||
|
key (same key string, validity reset).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{generatedKey.renewed
|
||||||
|
? "Key renewed — same key string, validity reset 15 days."
|
||||||
|
: "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 || !alias.trim() || !name.trim()}
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" /> Generate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiKeyCreationModal;
|
||||||
67
src/components/Modals/ApiKeyRevokeModal.js
Normal 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;
|
||||||
42
src/components/Modals/JsonSchemaModal.js
Normal 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;
|
||||||
79
src/components/Modals/MarkdownSettingModal.js
Normal 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;
|
||||||
71
src/components/Modals/PathSettingModal.js
Normal 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;
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
/*src/components/MainNavigation.css*/
|
|
||||||
.main-navigation {
|
.main-navigation {
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-option {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.main-navigation .navbar {
|
.main-navigation .navbar {
|
||||||
background-color: #333 !important;
|
background-color: #333 !important;
|
||||||
}
|
}
|
||||||
@@ -19,6 +22,10 @@
|
|||||||
color: #f5f5f5 !important;
|
color: #f5f5f5 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-end .navbar-item {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.main-navigation .button {
|
.main-navigation .button {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -54,4 +61,4 @@
|
|||||||
.main-navigation .navbar-item:not(:last-child) {
|
.main-navigation .navbar-item:not(:last-child) {
|
||||||
border-right: 1px solid #555;
|
border-right: 1px solid #555;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,225 @@
|
|||||||
import React, { useContext } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { AuthContext } from "../../AuthProvider";
|
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 MainNavigation = () => {
|
||||||
const { user, login, logout } = useContext(AuthContext);
|
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 (
|
return (
|
||||||
<nav className="navbar is-dark" role="navigation" aria-label="main navigation">
|
<>
|
||||||
<div className="navbar-brand">
|
<header className="glass relative z-20 flex h-14 shrink-0 items-center gap-1 border-b border-border px-4">
|
||||||
<Link className="navbar-item" to="/">
|
<Link
|
||||||
Home
|
to="/"
|
||||||
</Link>
|
className="group flex items-center gap-2.5 pr-3"
|
||||||
<a
|
|
||||||
role="button"
|
|
||||||
className="navbar-burger"
|
|
||||||
aria-label="menu"
|
|
||||||
aria-expanded="false"
|
|
||||||
data-target="navbarBasicExample"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.currentTarget.classList.toggle("is-active");
|
|
||||||
document
|
|
||||||
.getElementById("navbarBasicExample")
|
|
||||||
.classList.toggle("is-active");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span aria-hidden="true"></span>
|
<img
|
||||||
<span aria-hidden="true"></span>
|
src="/icons/hangman-lab-logo.svg"
|
||||||
<span aria-hidden="true"></span>
|
alt="Hangman Lab"
|
||||||
</a>
|
className="h-7 w-7"
|
||||||
</div>
|
/>
|
||||||
|
<span className="font-display text-base leading-none tracking-tight">
|
||||||
|
<span className="text-foreground">hangman</span>
|
||||||
|
<span className="neon-text"> lab</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div id="navbarBasicExample" className="navbar-menu">
|
<nav className="ml-2 hidden items-center gap-1 sm:flex">
|
||||||
<div className="navbar-start">
|
|
||||||
<a
|
<a
|
||||||
href="https://mail.hangman-lab.top"
|
href="https://mail.hangman-lab.top"
|
||||||
className="navbar-item"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
||||||
<a
|
<a
|
||||||
href="https://git.hangman-lab.top"
|
href="https://git.hangman-lab.top"
|
||||||
className="navbar-item"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
</a>
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
<div className="navbar-end">
|
<div className="ml-auto flex items-center">
|
||||||
{user && user.profile ? (
|
{user && user.profile ? (
|
||||||
<div className="navbar-item">
|
<DropdownMenu>
|
||||||
<div className="buttons">
|
<DropdownMenuTrigger asChild>
|
||||||
<span className="button is-primary is-light">
|
<Button
|
||||||
{user.profile.name}
|
variant="outline"
|
||||||
</span>
|
size="sm"
|
||||||
<button
|
className="gap-2"
|
||||||
className="button is-danger"
|
|
||||||
onClick={logout}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
Logout
|
<span className="grid h-5 w-5 place-items-center rounded-full bg-primary/15 font-mono text-[10px] font-bold text-primary">
|
||||||
</button>
|
{(user.profile.name || "?")
|
||||||
</div>
|
.charAt(0)
|
||||||
</div>
|
.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 size="sm" onClick={login} className="gap-2">
|
||||||
<button
|
<LogIn className="h-4 w-4" /> Login
|
||||||
className="button is-primary"
|
</Button>
|
||||||
onClick={login}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</nav>
|
<ApiKeyCreationModal
|
||||||
|
isOpen={isApiKeyModalOpen}
|
||||||
|
onClose={() => setIsApiKeyModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<ApiKeyRevokeModal
|
||||||
|
isOpen={isRevokeModalOpen}
|
||||||
|
onClose={() => setIsRevokeModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MainNavigation;
|
export default MainNavigation;
|
||||||
|
|||||||
92
src/components/Navigations/MarkdownNode.js
Normal 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;
|
||||||
@@ -8,6 +8,18 @@
|
|||||||
margin-left: 1rem;
|
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 {
|
.path-node > .has-text-weight-bold {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -35,3 +47,35 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-left: 1rem;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,221 +1,227 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
|
import { toggleNodeExpansion } from '../../store/navigationSlice';
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
Settings2,
|
||||||
|
Pencil,
|
||||||
|
Check,
|
||||||
|
Trash2,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
import PermissionGuard from "../PermissionGuard";
|
import PermissionGuard from "../PermissionGuard";
|
||||||
import config from "../../config";
|
import { useDeletePath, useMovePath, useUpdatePath } from "../../utils/queries/path-queries";
|
||||||
import "./PathNode.css";
|
import { useIndexMarkdown, useMoveMarkdown } from "../../utils/queries/markdown-queries";
|
||||||
import {fetch_} from "../../utils/requestUtils";
|
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 PathNode = ({ path, isRoot = false }) => {
|
||||||
const [children, setChildren] = useState([]);
|
const [isPathSettingModalOpen, setIsPathSettingModalOpen] = useState(false);
|
||||||
const [markdowns, setMarkdowns] = useState([]);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(isRoot); // Root is always expanded
|
const [newName, setNewName] = useState(path.name || "");
|
||||||
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 toggleExpand = () => {
|
const expandedNodes = useSelector(state => state.navigation.expandedNodes);
|
||||||
if (isRoot || isExpanded) {
|
const dispatch = useDispatch();
|
||||||
setIsExpanded(false);
|
const isExpanded = isRoot || expandedNodes[path.id];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
const expand = () => {
|
||||||
setLoading(true);
|
if (!isExpanded) dispatch(toggleNodeExpansion(path.id));
|
||||||
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 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 = () => {
|
const handleDelete = () => {
|
||||||
if (window.confirm(`Are you sure you want to delete ${path.name}?`)) {
|
if (window.confirm("Are you sure?")) {
|
||||||
fetch_(`${config.BACKEND_HOST}/api/path/${path.id}`, {
|
deletePath.mutate(path.id, {
|
||||||
method: "DELETE"
|
onError: () => alert("failed to delete this path"),
|
||||||
}, {
|
});
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleEdit = () => setIsEditing(true);
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleMovePath = (pth, direction) => {
|
||||||
setIsEditing(true);
|
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) => {
|
const childPaths = path.children.filter(x => x.type === "path");
|
||||||
e.preventDefault();
|
const sortedPaths = childPaths
|
||||||
fetch_(`${config.BACKEND_HOST}/api/path/${path.id}`, {
|
? childPaths.slice().sort((a, b) => a.order.localeCompare(b.order))
|
||||||
method: "PUT",
|
: [];
|
||||||
body: JSON.stringify(editData),
|
const markdowns = path.children.filter(x => x.type === "markdown");
|
||||||
},{
|
const sortedMarkdowns = markdowns
|
||||||
use_cache: false,
|
? markdowns.filter(md => md.title !== "index").sort((a, b) => a.order.localeCompare(b.order))
|
||||||
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 handleEditCancel = () => {
|
if (isRoot)
|
||||||
setEditData({ name: path.name, parent_id: path.parent_id });
|
return (
|
||||||
setIsEditing(false);
|
<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 (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<div className="path-node-header">
|
<div className="group flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-accent/60">
|
||||||
<span
|
<button
|
||||||
className={`has-text-weight-bold ${
|
type="button"
|
||||||
isRoot ? "" : "is-clickable"
|
onClick={toggleExpand}
|
||||||
}`}
|
title={isExpanded ? "Collapse" : "Expand"}
|
||||||
onClick={isRoot ? undefined : toggleExpand}
|
className="flex shrink-0 items-center gap-1 rounded text-muted-foreground transition-colors hover:text-primary"
|
||||||
style={{ cursor: isRoot ? "default" : "pointer" }}
|
|
||||||
>
|
>
|
||||||
{isExpanded && !isRoot ? "▼ " : isRoot ? "" : "▶ "}
|
<ChevronRight
|
||||||
{path.name}
|
className={cn(
|
||||||
</span>
|
"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 */}
|
{isEditing ? (
|
||||||
<PermissionGuard rolesRequired={["admin"]}>
|
<input
|
||||||
<span className="path-node-actions">
|
autoFocus
|
||||||
<button
|
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"
|
||||||
onClick={handleEdit}
|
value={newName}
|
||||||
className="button is-small is-info is-light"
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
type="button"
|
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||||
>
|
/>
|
||||||
Edit
|
) : indexMarkdown ? (
|
||||||
</button>
|
// Clicking the name navigates to the folder's index page
|
||||||
<button
|
// AND expands the subtree (expanded state is global, so
|
||||||
onClick={handleDelete}
|
// the children stay visible after navigation).
|
||||||
className="button is-small is-danger is-light"
|
<Link
|
||||||
type="button"
|
to={`/markdown/${indexMarkdown.id}`}
|
||||||
>
|
onClick={expand}
|
||||||
Delete
|
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground transition-colors hover:text-primary"
|
||||||
</button>
|
>
|
||||||
|
{path.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
onClick={toggleExpand}
|
||||||
|
className="min-w-0 flex-1 cursor-pointer truncate text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
{path.name}
|
||||||
</span>
|
</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>
|
</PermissionGuard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<ul>
|
<ul className="ml-3 space-y-0.5 border-l border-border/60 pl-2">
|
||||||
{/* Render child paths */}
|
{sortedPaths.map((child) => (
|
||||||
{loading && <p>Loading...</p>}
|
|
||||||
{children.map((child) => (
|
|
||||||
<PathNode key={child.id} path={child} />
|
<PathNode key={child.id} path={child} />
|
||||||
))}
|
))}
|
||||||
|
{sortedMarkdowns.map((markdown) => (
|
||||||
{/* Render markdowns */}
|
<MarkdownNode
|
||||||
{markdowns.map((markdown) => (
|
markdown={markdown}
|
||||||
<li key={markdown.id} className="menu-list-item">
|
handleMoveMarkdown={handleMoveMarkdown}
|
||||||
<Link
|
key={markdown.id}
|
||||||
to={`/markdown/${markdown.id}`}
|
/>
|
||||||
className="is-link"
|
|
||||||
>
|
|
||||||
{markdown.title}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/* src/components/SideNavigation.css */
|
|
||||||
.menu {
|
.menu {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9;
|
||||||
|
height: 90vh;
|
||||||
|
overflow-y: hidden;
|
||||||
|
min-width: 15vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-label {
|
.menu-label {
|
||||||
@@ -14,6 +16,8 @@
|
|||||||
|
|
||||||
.menu-list {
|
.menu-list {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-list-item {
|
.menu-list-item {
|
||||||
@@ -45,3 +49,34 @@
|
|||||||
.is-clickable:hover {
|
.is-clickable:hover {
|
||||||
color: #2759a7;
|
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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,44 +1,62 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useContext, useEffect } from 'react';
|
||||||
import PermissionGuard from "../PermissionGuard";
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import PathNode from "./PathNode";
|
import { setSelectedTab } from '../../store/navigationSlice';
|
||||||
import config from "../../config";
|
import { FolderTree, LayoutTemplate } from "lucide-react";
|
||||||
import "./SideNavigation.css";
|
import TreeTab from "./SideTabs/TreeTab";
|
||||||
import {fetch_} from "../../utils/requestUtils";
|
import TemplateTab from "./SideTabs/TemplateTab";
|
||||||
|
import { AuthContext } from "../../AuthProvider";
|
||||||
|
import { ScrollArea } from "../ui/scroll-area";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
const SideNavigation = () => {
|
const SideNavigation = () => {
|
||||||
const [paths, setPaths] = useState([]);
|
const { roles } = useContext(AuthContext);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
if (!visibleTabs.find(tab => tab.id === selectedTab)) {
|
||||||
fetch_(`${config.BACKEND_HOST}/api/path/`, {},{ use_cache: true, use_token:false })
|
dispatch(setSelectedTab(visibleTabs[0]?.id || ""));
|
||||||
.then((data) => {
|
}
|
||||||
setPaths(data);
|
}, [visibleTabs, selectedTab, dispatch]);
|
||||||
})
|
|
||||||
.catch((error) => console.log(error))
|
const current = visibleTabs.find(t => t.id === selectedTab);
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="menu">
|
<aside className="flex w-72 shrink-0 flex-col border-r border-border bg-surface/40">
|
||||||
<p className="menu-label">Markdown Directory</p>
|
<div className="flex items-center gap-1 border-b border-border p-2">
|
||||||
<PermissionGuard rolesRequired={["admin", "creator"]}>
|
{visibleTabs.map(tab => {
|
||||||
<a
|
const Icon = tab.icon;
|
||||||
href="/markdown/create"
|
const active = tab.id === selectedTab;
|
||||||
className="button is-primary is-small"
|
return (
|
||||||
>
|
<button
|
||||||
Create New Markdown
|
key={tab.id}
|
||||||
</a>
|
type="button"
|
||||||
</PermissionGuard>
|
onClick={() => dispatch(setSelectedTab(tab.id))}
|
||||||
<ul className="menu-list">
|
className={cn(
|
||||||
{loading && <p>Loading...</p>}
|
"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",
|
||||||
{paths.map((path) => (
|
active
|
||||||
<PathNode
|
? "bg-primary/15 text-primary shadow-[0_0_12px_-4px_hsl(var(--primary)/0.6)]"
|
||||||
key={path.id}
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
path={path}
|
)}
|
||||||
isRoot={false}
|
>
|
||||||
/>
|
<Icon className="h-3.5 w-3.5" />
|
||||||
))}
|
{tab.label}
|
||||||
</ul>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-3">{current?.component}</div>
|
||||||
|
</ScrollArea>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
184
src/components/Navigations/SideTabs/TemplateTab.js
Normal 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;
|
||||||
100
src/components/Navigations/SideTabs/TreeTab.js
Normal 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;
|
||||||
@@ -1,39 +1,5 @@
|
|||||||
.path-manager-body {
|
/* Styling moved to Tailwind / dark-tech design system in PathManager.js.
|
||||||
display: flex;
|
Only the scroll cap for the suggestions dropdown remains here. */
|
||||||
flex-direction: column;
|
.path-manager-dropdown {
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown .dropdown-input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown .dropdown-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 10;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
padding: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,65 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, {useEffect, useState, useRef, useContext} from "react";
|
||||||
import { fetch_ } from "../utils/requestUtils";
|
import {useCreatePath, usePaths} from "../utils/queries/path-queries";
|
||||||
import config from "../config";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import "./PathManager.css";
|
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 PathManager = ({ currentPathId = 1, onPathChange }) => {
|
||||||
const [currentPath, setCurrentPath] = useState([{ name: "Root", id: 1 }]);
|
const [currentFullPath, setCurrentFullPath] = useState([{ name: "Root", id: 1 }]);
|
||||||
const [currentId, setCurrentId] = useState(currentPathId);
|
|
||||||
const [subPaths, setSubPaths] = useState([]);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [dropdownActive, setDropdownActive] = useState(false);
|
const [dropdownActive, setDropdownActive] = useState(false);
|
||||||
|
|
||||||
const inputRef = useRef();
|
const inputRef = useRef();
|
||||||
|
|
||||||
useEffect(() => {
|
const queryClient = useQueryClient();
|
||||||
fetchSubPaths(currentId);
|
const { data: subPaths, isLoading: isSubPathsLoading, error: subPathsError } = usePaths(currentPathId);
|
||||||
}, [currentId]);
|
const createPath = useCreatePath();
|
||||||
|
const config = useContext(ConfigContext).config;
|
||||||
|
|
||||||
const fetchSubPaths = (pathId) => {
|
|
||||||
setLoading(true);
|
const buildFullPath = async (pathId) => {
|
||||||
fetch_(`${config.BACKEND_HOST}/api/path/parent/${pathId}`, {}, { use_cache: false, use_token: true })
|
const path = [];
|
||||||
.then((data) => setSubPaths(data))
|
let current_id = pathId;
|
||||||
.catch((error) => console.error("Failed to fetch subdirectories:", error))
|
while (current_id) {
|
||||||
.finally(() => setLoading(false));
|
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 handlePathClick = (pathId, pathIndex) => {
|
||||||
const newPath = currentPath.slice(0, pathIndex + 1);
|
const newPath = currentFullPath.slice(0, pathIndex + 1);
|
||||||
setCurrentPath(newPath);
|
setCurrentFullPath(newPath);
|
||||||
setCurrentId(pathId);
|
|
||||||
onPathChange(pathId);
|
onPathChange(pathId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubPathSelect = (subPath) => {
|
const handleSubPathSelect = (subPath) => {
|
||||||
const updatedPath = [...currentPath, { name: subPath.name, id: subPath.id }];
|
const updatedPath = [...currentFullPath, { name: subPath.name, id: subPath.id }];
|
||||||
setCurrentPath(updatedPath);
|
setCurrentFullPath(updatedPath);
|
||||||
setCurrentId(subPath.id);
|
|
||||||
onPathChange(subPath.id);
|
onPathChange(subPath.id);
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setDropdownActive(false);
|
setDropdownActive(false);
|
||||||
@@ -46,92 +70,107 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
|
|||||||
alert("Directory name cannot be empty.");
|
alert("Directory name cannot be empty.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch_(`${config.BACKEND_HOST}/api/path/`, {
|
|
||||||
method: "POST",
|
createPath.mutate(
|
||||||
body: JSON.stringify({ name: searchTerm.trim(), parent_id: currentId }),
|
{ name: searchTerm.trim(), parent_id: currentPathId },
|
||||||
}, { use_cache: false, use_token: true })
|
{
|
||||||
.then((newDir) => {
|
onSuccess: (newDir) => {
|
||||||
setSubPaths([...subPaths, newDir]);
|
queryClient.setQueryData({queryKey: ["path", newDir.id]}, newDir);
|
||||||
setSearchTerm("");
|
queryClient.invalidateQueries({queryKey: ["paths", currentPathId]});
|
||||||
alert("Directory created successfully!");
|
setSearchTerm("");
|
||||||
})
|
alert("Directory created successfully.");
|
||||||
.catch((error) => {
|
},
|
||||||
console.error("Failed to create directory:", error);
|
onError: (error) => {
|
||||||
alert("Failed to create directory.");
|
console.error("Failed to create directory:", error);
|
||||||
});
|
alert("Failed to create directory.");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputFocus = () => setDropdownActive(true);
|
const handleInputFocus = () => setDropdownActive(true);
|
||||||
|
|
||||||
const handleInputBlur = () => {
|
const handleInputBlur = () => {
|
||||||
setTimeout(() => setDropdownActive(false), 150);
|
setTimeout(() => setDropdownActive(false), 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredSubPaths = subPaths.filter((path) =>
|
const filteredSubPaths = subPaths
|
||||||
path.name.toLowerCase().includes(searchTerm.toLowerCase())
|
? subPaths.filter((path) =>
|
||||||
);
|
path.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddDirectory();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="path-manager">
|
<div className="path-manager space-y-3">
|
||||||
<div className="path-manager-header">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<div className="current-path">
|
{currentFullPath.map((path, index) => (
|
||||||
{currentPath.map((path, index) => (
|
<button
|
||||||
<span
|
type="button"
|
||||||
key={path.id}
|
key={path.id}
|
||||||
className="breadcrumb-item is-clickable"
|
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)}
|
onClick={() => handlePathClick(path.id, index)}
|
||||||
>
|
>
|
||||||
{path.name}
|
{path.name + "/"}
|
||||||
{index < currentPath.length - 1 && " / "}
|
</button>
|
||||||
</span>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="path-manager-body">
|
<div className="path-manager-body">
|
||||||
<div className="field has-addons">
|
<div className="flex items-center gap-2">
|
||||||
<div className="control">
|
<div className="relative flex-1">
|
||||||
<input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="input is-small"
|
className="h-8 text-xs"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search or create directory"
|
placeholder="Search or create directory"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
{dropdownActive && (
|
||||||
<div className="control">
|
<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">
|
||||||
<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">
|
|
||||||
{filteredSubPaths.length > 0 ? (
|
{filteredSubPaths.length > 0 ? (
|
||||||
filteredSubPaths.map((subPath) => (
|
filteredSubPaths.map((subPath) => (
|
||||||
<a
|
<button
|
||||||
|
type="button"
|
||||||
key={subPath.id}
|
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)}
|
onClick={() => handleSubPathSelect(subPath)}
|
||||||
>
|
>
|
||||||
{subPath.name}
|
{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>
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
412
src/components/Settings/PathSettings/WebhookSettingPanel.js
Normal 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
|
||||||
48
src/components/ui/button.js
Normal 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
@@ -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 };
|
||||||
92
src/components/ui/dialog.js
Normal 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,
|
||||||
|
};
|
||||||
72
src/components/ui/dropdown-menu.js
Normal 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,
|
||||||
|
};
|
||||||
45
src/components/ui/input.js
Normal 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
@@ -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,
|
||||||
|
};
|
||||||
25
src/components/ui/scroll-area.js
Normal 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
@@ -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 };
|
||||||