41 Commits

Author SHA1 Message Date
47606ac0a8 feat: darker dedicated favicon (#6a8018) for tab contrast
Logo (navbar) keeps the acid #d8ff3e; only the favicon uses a darker
acid for legibility on light browser tabs. Geometry identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:33:40 +01:00
16a0861f77 fix: absolute favicon path (/icons not ./icons) for SPA deep routes
Relative ./icons resolved against the current route (e.g. /markdown/1 ->
/markdown/icons/...) and hit the SPA fallback, so the favicon never loaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:26:30 +01:00
h z
f4787d0a27 Merge pull request 'feat: add 'agent' to API key role options' (#4) from feat/agent-role into main
Reviewed-on: #4
2026-05-17 14:10:05 +00:00
40f051bc77 feat: add 'agent' to API key role options
Keeps the Create API Key modal aligned with the backend allowlist
(admin|creator|user|agent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:06:28 +01:00
h z
9f2faed91f Merge pull request 'feat/lab-terminal-theme' (#3) from feat/lab-terminal-theme into main
Reviewed-on: #3
2026-05-17 13:16:40 +00:00
a98badf653 style: bump base font-size 17px -> 18px
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:12:15 +01:00
f9d618d0dd feat: self-host all external assets + larger base font
- Vendor Google Fonts: 23 woff2 + localized fonts.css under
  public/fonts/, referenced via /fonts/fonts.css. Drop the
  googleapis/gstatic preconnects and the (now unused) Font Awesome
  CDN link. No runtime external resource requests remain.
- Dockerfile copies public/fonts into the nginx image.
- html base font-size 16 -> 17px (all rem UI text scales up slightly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:09:46 +01:00
cbac2b0771 feat: lab-terminal theme + new logo
Adopt the Hangman Lab visual language (adapted from the Gitea
STYLE.md, Gitea-only mechanics excluded):

- New acid SVG mark as favicon (?v cache-bust) and navbar logo.
- Single acid accent (#d8ff3e); retune globals.css tokens to the
  ink/panel/line/text/dim/danger palette; drop the cyan/violet pair
  (secondary -> same acid so existing components stay on-brand).
- Fonts: IBM Plex Mono for body/UI/mono, Major Mono Display for the
  lowercase brand lockup; Tailwind fontFamily + content fonts updated.
- Site-wide fixed 48px blueprint-grid backdrop + one acid sheen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:05:16 +01:00
h z
117a1385ab Merge pull request 'feat/apikey-alias-authorship' (#2) from feat/apikey-alias-authorship into master
Reviewed-on: #2
2026-05-16 22:06:42 +00:00
3ec528701e feat: apikey alias field + markdown/patch authorship display
- ApiKeyCreationModal: required Alias input (reuse alias = renew, with
  banner); align roles to backend allowlist (guest -> user, default
  user); fix copy bug (generatedKey.key).
- MarkdownContent + PatchCards: show author / created / last modified
  (+ by whom); formatDateTime helper (null -> "—").

Branched off fix/buildconfig-cachebust (carries the contenthash +
BuildConfig fix already deployed to prod).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:51:47 +01:00
ba08bba7de fix: valid config.json + content-hashed bundle (cache-bust)
- BuildConfig.sh: ${DEBUG:false} -> ${DEBUG:-false} and normalize to
  true/false. The old syntax produced empty -> invalid config.json
  ("DEBUG": }) when DEBUG was unset, breaking the whole frontend.
- webpack: output [name].[contenthash].js so index.html references a
  unique bundle URL each build; eliminates stale CDN/browser bundle
  after deploys (no manual cache purge needed).

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:12:56 +01:00
c9310250e4 add: markdown deletion 2025-06-23 15:41:03 +01:00
a08164e914 add: route to stand along page 2025-06-23 12:18:26 +01:00
30a46d5064 add: markdown template to json schema 2025-05-12 09:59:23 +01:00
e5affe3465 improve: update css 2025-05-11 20:14:29 +01:00
101666d26d upgrade react-query to v5 2025-05-09 00:44:53 +01:00
87b4246a9b add: backend api auth by apikey/apikey gen/apikey revoke 2025-05-06 18:54:10 +01:00
1ce2eebbfa improve: upgrade node to 20, upgrade react-query to v4 2025-04-27 00:36:42 +01:00
9ea44385ee add: markdown permission setting
improve: template
2025-04-25 00:39:01 +01:00
c20cb168ff fix: template defects 2025-04-18 02:16:17 +01:00
137ea649f8 fix: template defects 2025-04-17 21:44:45 +01:00
947b59e3ea add: template editor 2025-04-14 17:02:22 +01:00
09338a2683 improve: add setting button to pathnode 2025-03-20 18:37:13 +00:00
dc0ff3b406 improve: change db schema for settings 2025-03-20 13:58:24 +00:00
2c330904e4 add: webhook 2025-03-17 13:54:53 +00:00
8abf54eade fix: table boarder not shown in md 2025-03-05 18:29:13 +00:00
dd1ee9fd5c add: load backup 2025-03-05 17:33:17 +00:00
2911f8722e add: tree / search 2025-03-05 01:23:09 +00:00
39a69ca5b8 add: auto link feature 2025-01-17 16:33:39 +00:00
76b298ac8b add: markdown search feature 2025-01-17 09:20:20 +00:00
3f4669f776 improve: minor change in main/side nav css 2025-01-16 18:00:44 +00:00
cdf9039049 add: provide backup archive feature 2025-01-16 14:05:19 +00:00
74326f60c3 add: toggle button for path 2025-01-16 09:52:04 +00:00
cfcf823e4c improve: different color for path/md node 2025-01-09 14:19:31 +00:00
ed13196ef8 improve: support todo check list in md 2025-01-08 14:30:50 +00:00
2837edef31 improve: css for change order 2024-12-29 19:30:46 +00:00
75d083f11f add: order paths & mds 2024-12-29 18:52:39 +00:00
34ab63d0bf fix: latex in md creates scroll bar for root 2024-12-15 07:39:38 +00:00
107 changed files with 8325 additions and 1101 deletions

1
.gitignore vendored
View File

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

View File

@@ -5,7 +5,12 @@ FRONTEND_HOST="${FRONTEND_HOST:-http://localhost:80}"
KC_CLIENT_ID="${KC_CLIENT_ID:-labdev}" KC_CLIENT_ID="${KC_CLIENT_ID:-labdev}"
KC_HOST="${KC_HOST:-https://login.hangman-lab.top}" KC_HOST="${KC_HOST:-https://login.hangman-lab.top}"
KC_REALM="${KC_REALM:-Hangman-Lab}" 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 rm -f /usr/share/nginx/html/config.js
@@ -24,7 +29,8 @@ cat <<EOL > /usr/share/nginx/html/config.json
"scope": "openid profile email roles", "scope": "openid profile email roles",
"popup_redirect_uri": "${FRONTEND_HOST}/popup_callback", "popup_redirect_uri": "${FRONTEND_HOST}/popup_callback",
"silent_redirect_uri": "${FRONTEND_HOST}/silent_callback" "silent_redirect_uri": "${FRONTEND_HOST}/silent_callback"
} },
"DEBUG": ${DEBUG}
} }
EOL EOL

View File

@@ -1,4 +1,4 @@
FROM node:18-alpine AS build-stage FROM node:20-alpine AS build-stage
WORKDIR /app WORKDIR /app
@@ -19,6 +19,7 @@ COPY package*.json ./
COPY --from=build-stage /app/dist /usr/share/nginx/html 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/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 nginx.conf /etc/nginx/conf.d/default.conf
COPY BuildConfig.sh /docker-entrypoint.d/10-build-config.sh COPY BuildConfig.sh /docker-entrypoint.d/10-build-config.sh
RUN chmod a+x /docker-entrypoint.d/10-build-config.sh RUN chmod a+x /docker-entrypoint.d/10-build-config.sh

2759
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,19 +12,40 @@
"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", "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-query": "^3.39.3", "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",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"tailwind-merge": "^2.6.0",
"util": "^0.12.5" "util": "^0.12.5"
}, },
"devDependencies": { "devDependencies": {
@@ -34,10 +55,13 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"autoprefixer": "^10.4.20",
"axios-mock-adapter": "^2.1.0", "axios-mock-adapter": "^2.1.0",
"babel-jest": "^29.7.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", "dotenv-webpack": "^8.1.0",
"html-webpack-plugin": "^5.6.3", "html-webpack-plugin": "^5.6.3",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
@@ -47,6 +71,7 @@
"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
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

207
public/fonts/fonts.css Normal file
View 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;
}

View 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

View 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

View File

@@ -1,18 +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/png" href="./icons/logo.png"> <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>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css"
rel="stylesheet"
/>
</body> </body>
</html> </html>

View File

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

View File

@@ -1,44 +1,58 @@
import React from "react"; import React from "react";
import {BrowserRouter as Router, Navigate, 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/Markdowns/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 "./components/KeycloakCallbacks/Callback"; import Callback from "./components/KeycloakCallbacks/Callback";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import PopupCallback from "./components/KeycloakCallbacks/PopupCallback"; import PopupCallback from "./components/KeycloakCallbacks/PopupCallback";
import SilentCallback from "./components/KeycloakCallbacks/SilentCallback"; import SilentCallback from "./components/KeycloakCallbacks/SilentCallback";
import {useHomeMarkdown} from "./utils/markdown-queries"; import MarkdownTemplateEditor from "./components/MarkdownTemplate/MarkdownTemplateEditor";
import { TooltipProvider } from "./components/ui/misc";
const App = () => { const App = () => {
return ( return (
<Provider store={store}>
<TooltipProvider delayDuration={200}>
<Router> <Router>
<div className="app-container"> <Routes>
<Route path="/pg/*" element={<StandaloneMarkdownPage />} />
<Route path="*" element={
<div className="relative z-10 flex h-screen flex-col">
<MainNavigation /> <MainNavigation />
<div className="content-container"> <div className="flex flex-1 overflow-hidden">
<SideNavigation /> <SideNavigation />
<main className="main-content"> <main className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-5xl px-6 py-8 pb-28">
<Routes> <Routes>
<Route <Route
path="/" path="/"
element={<Navigate to = "/markdown/1"/>} element={<Navigate to="/markdown/1" />}
/> />
<Route path="/testx" element={<h2>test2</h2>}/> <Route path="/markdown/:strId" element={<MarkdownContent />} />
<Route path="/markdown/:id" element={<MarkdownContent />} />
<Route path="/callback" element={<Callback />} /> <Route path="/callback" element={<Callback />} />
<Route path="/test" element={<h1>TEST</h1>}></Route>
<Route path="/markdown/create" element={<MarkdownEditor />} /> <Route path="/markdown/create" element={<MarkdownEditor />} />
<Route path="/markdown/edit/:id" element={<MarkdownEditor />} /> <Route path="/markdown/edit/:strId" element={<MarkdownEditor />} />
<Route path="/popup_callback" element={<PopupCallback />} /> <Route path="/popup_callback" element={<PopupCallback />} />
<Route path="silent_callback" element={<SilentCallback />} /> <Route path="/silent_callback" element={<SilentCallback />} />
<Route path="/template/create" element={<MarkdownTemplateEditor />} />
<Route path="/template/edit/:strId" element={<MarkdownTemplateEditor />} />
</Routes> </Routes>
</div>
</main> </main>
</div> </div>
</div>
<Footer /> <Footer />
</div>
} />
</Routes>
</Router> </Router>
</TooltipProvider>
</Provider>
); );
}; };

View File

@@ -1,4 +1,3 @@
// src/AuthProvider.js
import React, { createContext, useContext, 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 { ConfigContext } from "./ConfigProvider"; import { ConfigContext } from "./ConfigProvider";

View File

@@ -33,7 +33,6 @@ const ConfigProvider = ({ children }) => {
return res.json(); return res.json();
}) })
.then((data) => { .then((data) => {
console.log(data);
setConfig(data); setConfig(data);
setIsLoading(false); setIsLoading(false);
}) })

View File

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

View File

@@ -1,85 +1,62 @@
import React from "react"; import React from "react";
import "./Footer.css"; import { ChevronUp, Mail, GitBranch, Linkedin } from "lucide-react";
import { cn } from "../lib/utils";
const Footer = () => { const Footer = () => {
const [isExpanded, setIsExpanded] = React.useState(false); const [open, setOpen] = React.useState(false);
const [isVisible, setIsVisible] = React.useState(false);
const toggleExpand = () => {
if(!isExpanded) {
setIsVisible(true);
}
setIsExpanded((prev) => !prev);
};
const onTransitionEnd = () => {
if(!isExpanded) {
setIsVisible(false);
}
};
return ( return (
<footer className={`footer ${isExpanded ? "expanded" : ""}`}> <footer className="glass fixed bottom-0 left-0 right-0 z-20 border-t border-border">
<button
className="toggle-button"
onClick={toggleExpand}
type="button"
>
{isExpanded ? "↓" : "↑"}
</button>
<div className={`footer-content`}>
<p>&copy; {new Date().getFullYear()} Hangman Lab. {!isVisible && (<span>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="mailto:hzhang@hangman-lab.top">email</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://git.hangman-lab.top/hzhang/HangmanLab">git</a>
&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;
v0.0.6
</span>
)}</p>
{
isVisible && (
<div <div
className={`footer-details ${isExpanded ? "expanded" : ""}`} className={cn(
onTransitionEnd={onTransitionEnd} "overflow-hidden transition-all duration-300",
open ? "max-h-20" : "max-h-0"
)}
> >
<div className="footer-icons"> <div className="flex items-center justify-center gap-6 py-3 text-xs">
<a <a
href="https://www.linkedin.com/in/zhhrozhh/" href="https://www.linkedin.com/in/zhhrozhh/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
> >
<img <Linkedin className="h-3.5 w-3.5" /> LinkedIn
src="/icons/linkedin.png"
alt="LinkedIn"
className="footer-icon"
/>
LinkedIn
</a> </a>
<a <a
href="https://git.hangman-lab.top/hzhang/HangmanLab" href="https://git.hangman-lab.top/hzhang/HangmanLab"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
> >
<img <GitBranch className="h-3.5 w-3.5" /> Git
src="/icons/git.png"
alt="Git"
className="footer-icon"
/>
Git
</a> </a>
<a href="mailto:hzhang@hangman-lab.top"> <a
<img href="mailto:hzhang@hangman-lab.top"
src="/icons/email.png" className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
alt="Email" >
className="footer-icon" <Mail className="h-3.5 w-3.5" /> Email
/>
Email
</a> </a>
</div> </div>
</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> </div>
</footer> </footer>
); );
}; };

View File

@@ -1,6 +1,7 @@
import React, {useContext, useEffect} from "react"; import React, {useContext, useEffect} from "react";
import { UserManager } from "oidc-client-ts"; import { UserManager } from "oidc-client-ts";
import {ConfigContext} from "../../ConfigProvider"; import {ConfigContext} from "../../ConfigProvider";
import { Spinner } from "../ui/misc";
const Callback = () => { const Callback = () => {
@@ -13,7 +14,11 @@ const Callback = () => {
}); });
}, []); }, []);
return <div>Logging in...</div>; return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Logging in" />
</div>
);
}; };
export default Callback; export default Callback;

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useContext } from "react"; import React, { useEffect, useContext } from "react";
import { UserManager } from "oidc-client-ts"; import { UserManager } from "oidc-client-ts";
import {ConfigContext} from "../../ConfigProvider"; import {ConfigContext} from "../../ConfigProvider";
import { Spinner } from "../ui/misc";
const PopupCallback = () => { const PopupCallback = () => {
@@ -18,7 +19,11 @@ const PopupCallback = () => {
}); });
}, [config]); }, [config]);
return <div>Processing...</div>; return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Processing" />
</div>
);
}; };
export default PopupCallback; export default PopupCallback;

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useContext } from "react"; import React, { useEffect, useContext } from "react";
import { UserManager } from "oidc-client-ts"; import { UserManager } from "oidc-client-ts";
import { ConfigContext } from "../../ConfigProvider"; import { ConfigContext } from "../../ConfigProvider";
import { Spinner } from "../ui/misc";
const SilentCallback = () => { const SilentCallback = () => {
const { config } = useContext(ConfigContext); const { config } = useContext(ConfigContext);
@@ -15,7 +16,11 @@ const SilentCallback = () => {
}); });
}, [config]); }, [config]);
return <div>Renew...</div>; return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Renewing session" />
</div>
);
}; };
export default SilentCallback; export default SilentCallback;

View File

@@ -0,0 +1,57 @@
import React, { useState } from "react";
import { Plus, X } from "lucide-react";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
const EnumsEditor = ({ enums, onChange }) => {
const [_enums, setEnums] = useState(enums || []);
return (
<div className="space-y-3 rounded-md border border-border bg-background/40 p-4">
<ul className="space-y-2">
{_enums.map((item, index) => (
<li key={index} className="flex items-center gap-2">
<Input
className="h-8 text-xs"
type="text"
value={item}
onChange={(e) => {
const updated = [..._enums];
updated[index] = e.target.value;
setEnums(updated);
onChange(updated);
}}
/>
<Button
type="button"
variant="destructive"
size="icon-sm"
onClick={() => {
const updated = [..._enums];
updated.splice(index, 1);
setEnums(updated);
onChange(updated);
}}
>
<X className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const updated = [..._enums, ""];
setEnums(updated);
onChange(updated);
}}
>
<Plus className="h-4 w-4" /> Add Enum
</Button>
</div>
);
};
export default EnumsEditor;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
import React, { useEffect, useState } from "react";
import { useMarkdownTemplates } from "../../utils/queries/markdown-template-queries";
import { Label } from "../ui/input";
import { Spinner } from "../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
const TemplateSelector = ({ template, onChange, onCreate }) => {
const { data: templates, isFetching: templatesAreFetching } = useMarkdownTemplates();
const [_template, setTemplate] = useState(
templates?.find((t) => t.id === template?.id) || {
title: "",
parameters: [],
layout: "",
}
);
useEffect(() => {
setTemplate(
templates?.find((t) => t.id === template?.id) || {
title: "",
parameters: [],
layout: "",
}
);
}, [template, templates]);
if (templatesAreFetching) {
return <Spinner label="Loading templates" />;
}
return (
<div className="space-y-2">
<Label htmlFor="template-selector">Select Template</Label>
<select
id="template-selector"
className={SELECT_CLASS}
value={template?.id || ""}
onChange={(e) => {
const id = parseInt(e.target.value, 10);
const selectedTemplate = templates.find((t) => t.id === id) || {
title: "",
parameters: [],
layout: "",
};
onChange(selectedTemplate);
if (onCreate) {
onCreate(selectedTemplate);
}
}}
>
<option value="">(None)</option>
{templates.map((tmpl) => (
<option key={tmpl.id} value={tmpl.id}>
{tmpl.title}
</option>
))}
</select>
</div>
);
};
export default TemplateSelector;

View File

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

View File

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

View File

@@ -2,17 +2,31 @@ import React, { useEffect, useState } from "react";
import {Link, useParams} from "react-router-dom"; import {Link, useParams} from "react-router-dom";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import "./MarkdownContent.css"; import "./MarkdownContent.css";
import { Settings2, Pencil, User, Clock, History } from "lucide-react";
import { formatDateTime } from "../../lib/utils";
import MarkdownView from "./MarkdownView"; import MarkdownView from "./MarkdownView";
import PatchCards from "./PatchCards";
import PermissionGuard from "../PermissionGuard"; import PermissionGuard from "../PermissionGuard";
import {useMarkdown} from "../../utils/markdown-queries"; import {useMarkdown} from "../../utils/queries/markdown-queries";
import {usePath} from "../../utils/path-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 MarkdownContent = () => {
const { id } = useParams(); const { strId } = useParams();
const id = Number(strId);
const [indexTitle, setIndexTitle] = useState(null); const [indexTitle, setIndexTitle] = useState(null);
const [isSettingModalOpen, setSettingModalOpen] = useState(false);
const {data: markdown, isLoading, error} = useMarkdown(id); const {data: markdown, isLoading, error} = useMarkdown(id);
const {data: path, isFetching: isPathFetching} = usePath(markdown?.path_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(() => { useEffect(() => {
if(markdown && markdown.title === "index" && path){ if(markdown && markdown.title === "index" && path){
@@ -21,27 +35,82 @@ const MarkdownContent = () => {
}, [markdown, path]); }, [markdown, path]);
if (isLoading || isPathFetching) { const notReady = isLoading || isPathFetching || isSettingFetching || isTemplateSettingFetching || isTemplateFetching;
return <div>Loading...</div>;
if (notReady) {
return (
<div className="flex justify-center py-20">
<Spinner label="Loading content" />
</div>
);
} }
if (error) { if (error) {
return <div>Error: {error.message || "Failed to load content"}</div>; 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 ( return (
<div className="markdown-content-container"> <article>
<div className="field has-addons markdown-content-container-header"> <div className="mb-6 flex items-start justify-between gap-4 border-b border-border pb-4">
<h1 className="title control">{markdown.title === "index" ? indexTitle : markdown.title}</h1> <h1 className="font-mono text-3xl font-bold tracking-tight text-foreground">
{markdown.title === "index" ? indexTitle : markdown.title}
</h1>
<PermissionGuard rolesRequired={['admin']}> <PermissionGuard rolesRequired={['admin']}>
<Link to={`/markdown/edit/${id}`} className="control button is-primary is-light"> <div className="flex shrink-0 items-center gap-2">
Edit <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> </Link>
</Button>
</div>
</PermissionGuard> </PermissionGuard>
</div> </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">
<MarkdownView content={markdown.content}/> <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> </div>
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/>
<PatchCards markdownId={id} />
<MarkdownSettingModal
isOpen={isSettingModalOpen}
markdown={markdown}
onClose={() => setSettingModalOpen(false)}
/>
</article>
); );
}; };

View File

@@ -1,95 +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-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;
}

View File

@@ -5,117 +5,329 @@ import "katex/dist/katex.min.css";
import "./MarkdownEditor.css"; import "./MarkdownEditor.css";
import PathManager from "../PathManager"; import PathManager from "../PathManager";
import MarkdownView from "./MarkdownView"; import MarkdownView from "./MarkdownView";
import { useMarkdown, useSaveMarkdown } from "../../utils/markdown-queries"; 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 {data: markdown, isLoading, error} = useMarkdown(id); 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 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(markdown){ if (markdown) {
setTitle(markdown.title); setTitle(markdown.title);
setContent(markdown.content); if (markdown.isMessage) {
setPathId(markdown.path_id); navigate("/");
alert(markdown.content || "Cannot edit this markdown");
return;
} }
}, [markdown]); try {
const parsedContent = JSON.parse(markdown.content);
setContent(parsedContent);
setRawContent(JSON.stringify(parsedContent, null, 2));
setShortcut(markdown.shortcut);
setPathId(markdown.path_id);
} catch (e) {
console.error("Error parsing markdown content:", e);
alert("Error parsing markdown content");
navigate("/");
}
}
}, [markdown, navigate]);
useEffect(() => {
if (template) {
setSelectedTemplate(template);
}
}, [template]);
const handleSave = () => { const handleSave = () => {
if (isRawMode && jsonError) {
alert("Please fix the JSON errors before saving");
return;
}
const saveData = {
title,
content: JSON.stringify(content),
path_id: pathId,
shortcut
};
console.log("markdown", markdown);
console.log(markdown?.id ? "update" : "create",)
if (!markdown?.id) {
saveMarkdown.mutate( saveMarkdown.mutate(
{id, data: {title, content, path_id: pathId}}, {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: () => { onSuccess: () => {
navigate("/"); navigate("/");
}
});
}
});
} else {
navigate("/");
}
}
});
}
});
}, },
onError: () => { onError: () => {
alert("Error saving markdown file"); 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(isLoading)
return <p>Loading...</p>;
if(error) if(error)
return <p>{error.message || "Failed to load markdown"}</p>;
return ( return (
<div className="container mt-5 markdown-editor-container"> <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
<h2 className="title is-4">{id ? "Edit Markdown" : "Create Markdown"}</h2> {error.message || "Failed to load markdown"}
<div className="columns"> </div>
{/* Editor Column */} );
<div className="column is-half"> return (
<form> <div className="markdown-editor-container mx-auto max-w-[90vw] px-6 py-8">
{/* Title Field */} <h2 className="mb-6 font-mono text-2xl font-bold tracking-tight text-foreground">
<div className="field"> {id ? "Edit Markdown" : "Create Markdown"}
<label className="label">Title</label> </h2>
<div className="control"> <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<input <div>
className="input" <form className="space-y-5">
<div className="space-y-2">
<Label htmlFor="md-title">Title</Label>
<Input
id="md-title"
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"
variant="outline"
size="sm"
onClick={toggleEditMode}
>
{isRawMode ? (
<>
<LayoutTemplate className="h-4 w-4" /> Switch to Template Editor
</>
) : (
<>
<Code className="h-4 w-4" /> Switch to Raw Editor
</>
)}
</Button>
</div>
{isRawMode ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Edit the JSON directly. Make sure it's valid JSON before saving.
</p>
<Textarea
className={`raw-editor h-[70vh] font-mono text-sm ${jsonError ? "border-destructive focus-visible:border-destructive" : ""}`}
value={rawContent}
onChange={handleRawContentChange}
placeholder="Enter JSON content here"
/>
{jsonError && (
<p className="font-mono text-xs text-destructive">{jsonError}</p>
)}
</div>
) : (
<TemplatedEditor
style={{height: "40vh"}}
content={content}
template={!markdown?.id ? selectedTemplate : template}
onContentChanged={(k, v) => setContent(
prev => ({...prev, [k]: v})
)}
/>
)}
</div>
<div>
<Button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={saveMarkdown.isLoading} disabled={saveMarkdown.isLoading}
> >
<Save className="h-4 w-4" />
{saveMarkdown.isLoading ? "Saving..." : "Save"} {saveMarkdown.isLoading ? "Saving..." : "Save"}
</button> </Button>
</div>
</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
<MarkdownView content={content} height='70vh'/> </h3>
<MarkdownView
content={content}
template={!markdown?.id ? selectedTemplate : template}
height='70vh'
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,95 +1,173 @@
/* Dark-tech prose theme for rendered markdown. */
.markdown-preview { .markdown-preview {
padding: 15px; color: hsl(var(--foreground) / 0.92);
border: 1px solid #ddd; line-height: 1.75;
border-radius: 8px; font-size: 0.975rem;
background-color: #ffffff;
white-space: normal; white-space: normal;
word-wrap: break-word; word-wrap: break-word;
overflow-x: auto; overflow-x: auto;
} }
.katex-display { .markdown-preview > *:first-child {
margin: 1em 0; margin-top: 0;
text-align: center;
}
.katex {
font-size: 1.2rem;
} }
code { .markdown-preview p {
font-family: 'Courier New', Courier, monospace; margin: 0 0 1rem;
background-color: #f4f4f4;
padding: 2px 4px;
border-radius: 4px;
} }
pre {
background-color: #2d2d2d; .markdown-preview a {
color: #f8f8f2; color: hsl(var(--primary));
padding: 10px; text-decoration: none;
border-radius: 6px; border-bottom: 1px solid hsl(var(--primary) / 0.3);
overflow-x: auto; 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 ul,
.markdown-preview ol { .markdown-preview ol {
padding-left: 1.5rem; padding-left: 1.5rem;
margin-bottom: 1rem; margin: 0 0 1rem;
} }
.markdown-preview ul { .markdown-preview ul {
list-style-type: disc; list-style-type: disc;
} }
.markdown-preview ol { .markdown-preview ol {
list-style-type: decimal; list-style-type: decimal;
} }
.markdown-preview li { .markdown-preview li {
margin-bottom: 0.5rem; margin-bottom: 0.4rem;
}
.markdown-preview li::marker {
color: hsl(var(--primary) / 0.7);
} }
.markdown-preview h1 { .markdown-preview blockquote {
font-size: 2em; margin: 0 0 1rem;
font-weight: bold; padding: 0.5rem 1rem;
margin-bottom: 0.5rem; border-left: 3px solid hsl(var(--secondary));
color: #333; background: hsl(var(--secondary) / 0.08);
color: hsl(var(--foreground) / 0.8);
border-radius: 0 6px 6px 0;
} }
.markdown-preview h2 { .markdown-preview hr {
border: none;
font-size: 1.75em; border-top: 1px solid hsl(var(--border));
font-weight: bold; margin: 2rem 0;
margin-bottom: 0.5rem;
color: #444;
} }
.markdown-preview h3 { .markdown-preview img {
font-size: 1.5em; max-width: 100%;
font-weight: bold; border-radius: 8px;
margin-bottom: 0.5rem; border: 1px solid hsl(var(--border));
color: #555;
} }
/* Inline code */
.markdown-preview h4 { .markdown-preview :not(pre) > code {
font-size: 1.25em; font-family: "IBM Plex Mono", ui-monospace, monospace;
font-weight: bold; font-size: 0.85em;
margin-bottom: 0.5rem; background-color: hsl(var(--muted));
color: #666; color: hsl(var(--primary));
padding: 0.15em 0.4em;
border-radius: 4px;
border: 1px solid hsl(var(--border));
} }
.markdown-preview h5 { /* Fenced code blocks (react-syntax-highlighter wraps in <pre>) */
font-size: 1.1em; .markdown-preview pre {
font-weight: bold; background-color: hsl(222 40% 4%) !important;
margin-bottom: 0.5rem; border: 1px solid hsl(var(--border));
color: #777; 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 h6 { .markdown-preview table {
font-size: 1em; width: 100%;
font-weight: bold; border-collapse: collapse;
margin-bottom: 0.5rem; margin: 0 0 1rem;
color: #888; 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));
}

View File

@@ -3,18 +3,94 @@ import ReactMarkdown from "react-markdown";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw"; 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 { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism"; import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import "./MarkdownView.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"
};
const MarkdownView = ({ content, height="auto" }) => {
return ( return (
<div className="markdown-preview" style={{height}}> <div className="markdown-preview" style={{height}}>
<ReactMarkdown <ReactMarkdown
children={content} children={ParseTemplate({
remarkPlugins={[remarkMath]} template: _template,
rehypePlugins={[rehypeKatex, rehypeRaw]} variables: content
}) + "\n" + linkDefinitions}
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
components={{ components={{
code({ node, inline, className, children, ...props }) { code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || ""); const match = /language-(\w+)/.exec(className || "");

View 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;

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import "katex/dist/katex.min.css";
import "./MarkdownContent.css";
import MarkdownView from "./MarkdownView";
import { useMarkdown } from "../../utils/queries/markdown-queries";
import { useMarkdownSetting } from "../../utils/queries/markdown-setting-queries";
import { useMarkdownTemplate } from "../../utils/queries/markdown-template-queries";
import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-template-setting-queries";
import { useTree } from "../../utils/queries/tree-queries";
import { getMarkdownIdByPath } from "../../utils/pathUtils";
import { parseMarkdownContent } from "../../utils/safe-json";
import { Spinner } from "../ui/misc";
const StandaloneMarkdownPage = () => {
const location = useLocation();
const [indexTitle, setIndexTitle] = useState(null);
const [markdownId, setMarkdownId] = useState(null);
// Extract path from /pg/project/index -> project/index
const pathString = location.pathname.replace(/^\/pg\//, '');
const { data: tree, isLoading: isTreeLoading } = useTree();
const { data: markdown, isLoading: isMarkdownLoading, error } = useMarkdown(markdownId);
const { data: setting, isFetching: isSettingFetching } = useMarkdownSetting(markdown?.setting_id);
const { data: templateSetting, isFetching: isTemplateSettingFetching } = useMarkdownTemplateSetting(setting?.template_setting_id);
const { data: template, isFetching: isTemplateFetching } = useMarkdownTemplate(templateSetting?.template_id);
// Resolve markdown ID from path using tree
useEffect(() => {
if (tree && pathString) {
const resolvedId = getMarkdownIdByPath(tree, pathString);
setMarkdownId(resolvedId);
}
}, [tree, pathString]);
useEffect(() => {
if (markdown && markdown.title === "index" && pathString) {
const pathParts = pathString.split('/').filter(part => part.length > 0);
if (pathParts.length === 0) {
// Root index: /pg/ or /pg
setIndexTitle("Home");
} else {
// Directory index: /pg/Projects or /pg/Projects/project1
// Use the last directory name as title
const directoryName = pathParts[pathParts.length - 1];
setIndexTitle(directoryName);
}
}
}, [markdown, pathString]);
const notReady = isTreeLoading || isMarkdownLoading || isSettingFetching || isTemplateSettingFetching || isTemplateFetching;
if (notReady) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Loading" />
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center px-6">
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
Error: {error.message || "Failed to load content"}
</div>
</div>
);
}
if (!notReady && !markdownId) {
return (
<div className="flex min-h-screen items-center justify-center px-6">
<p className="font-mono text-sm text-muted-foreground">
Markdown not found for path:{" "}
<span className="text-foreground">{pathString}</span>
</p>
</div>
);
}
if (markdown?.isMessage) {
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<div className="rounded-lg border border-primary/30 bg-primary/10 px-5 py-4">
<h4 className="mb-1 font-mono text-base font-semibold text-primary">
{markdown.title}
</h4>
<p className="text-sm text-foreground/80">
{markdown.content}
</p>
</div>
</div>
);
}
return (
<div className="relative z-10 mx-auto max-w-4xl px-6 py-12">
<h1 className="mb-8 border-b border-border pb-4 font-mono text-3xl font-bold tracking-tight text-foreground">
{markdown?.title === "index" ? indexTitle : markdown?.title}
</h1>
{markdown && (
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template} />
)}
</div>
);
};
export default StandaloneMarkdownPage;

View File

@@ -0,0 +1,179 @@
import React, {useState} from "react";
import { Plus, Trash2 } from "lucide-react";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
import { Input, Textarea, Label } from "../ui/input";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
const FIELD_BOX = "space-y-2 rounded-md border border-border bg-surface/40 p-4";
const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged }) => {
console.log("variable", variable);
const __namespace = `${variable.name}(${variable.type.base_type})`;
const renderField = () => {
switch (variable.type.base_type) {
case "string":
return (
<div className={FIELD_BOX}>
<Label>{__namespace}</Label>
<Input
type="text"
value={value ?? ""}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
</div>
);
case "markdown":
return (
<div className={FIELD_BOX}>
<Label>{__namespace}</Label>
<Textarea
className="max-h-[10vh] font-mono text-sm"
value={value}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
</div>
);
case "enum":
return (
<div className={FIELD_BOX}>
<Label>{__namespace}</Label>
<select
className={SELECT_CLASS}
value={value}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
>
{variable.type.definition.enums.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
</div>
);
case "list": {
const [cache, setCache] = useState(value || []);
const defaultValue = variable.type.definition.default;
const addItem = () => {
const newCache = [...cache, defaultValue];
setCache(newCache);
onContentChanged(variable.name, newCache);
};
const removeItem = (index) => {
const newCache = cache.filter((_, i) => i !== index);
setCache(newCache);
onContentChanged(variable.name, newCache);
};
const onItemChange = (index, val) => {
const newCache = [...cache];
newCache[index] = val;
setCache(newCache);
onContentChanged(variable.name, newCache);
};
return (
<div className={FIELD_BOX}>
<Label>{__namespace}</Label>
{cache.map((item, idx) => (
<div className="flex items-start gap-2" key={idx}>
<div className="flex-1">
<TemplatedEditorComponent
variable={{ name: idx, type: variable.type.extend_type }}
value={item}
namespace={__namespace}
onContentChanged={(subKey, subVal) => onItemChange(idx, subVal)}
/>
</div>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => removeItem(idx)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
);
}
case "template": {
const { data: _template, isFetching: loading } = useMarkdownTemplate(variable.type.definition.template.id);
if (loading) return <Spinner label="Loading template" />;
const _parameters = _template.parameters;
const handleSubChange = (key, val) => {
const updated = { ...(value || {}), [key]: val };
onContentChanged(variable.name, updated);
};
return (
<div className={FIELD_BOX}>
<Label>{__namespace}</Label>
{_parameters.map((param, i) => (
<div key={i}>
<TemplatedEditorComponent
variable={param}
value={(value || {})[param.name]}
namespace={__namespace}
onContentChanged={handleSubChange}
/>
</div>
))}
</div>
);
}
default:
return null;
}
};
return <>{renderField()}</>;
};
const TemplatedEditor = ({ content, template, onContentChanged, style }) => {
const tpl = template || {
parameters: [{ name: "markdown", type: { base_type: "markdown", definition: {} } }],
layout: "<markdown/>",
title: "default",
};
return (
<div
className="flex flex-col overflow-hidden rounded-lg border border-border bg-card"
style={style}
>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{tpl.parameters.map((variable, idx) => (
<TemplatedEditorComponent
key={idx}
variable={variable}
value={content[variable.name]}
namespace={tpl.title}
onContentChanged={onContentChanged}
/>
))}
</div>
</div>
);
};
export default TemplatedEditor;

View File

@@ -0,0 +1,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;

View File

@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { useRevokeApiKey } from '../../utils/queries/apikey-queries';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Input, Label } from '../ui/input';
const ApiKeyRevokeModal = ({ isOpen, onClose }) => {
const [apiKey, setApiKey] = useState('');
const revokeApiKeyMutation = useRevokeApiKey();
const handleRevoke = async () => {
if (!apiKey.trim()) {
alert('Please enter an API key');
return;
}
try {
await revokeApiKeyMutation.mutateAsync(apiKey);
alert('API key revoked successfully');
onClose();
} catch (error) {
console.error('Failed to revoke API key:', error);
alert('Failed to revoke API key');
}
};
return (
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Revoke API Key</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="revoke-api-key">API Key</Label>
<Input
id="revoke-api-key"
type="text"
placeholder="Enter API key to revoke"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleRevoke}
disabled={revokeApiKeyMutation.isLoading}
>
<Trash2 className="h-4 w-4" /> Revoke
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ApiKeyRevokeModal;

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
import {useUpdatePath} from "../../utils/queries/path-queries";
import {useCreatePathSetting, usePathSetting} from "../../utils/queries/path-setting-queries";
import WebhookSettingPanel from "../Settings/PathSettings/WebhookSettingPanel";
import React, {useState} from "react";
import { Plus } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
const PathSettingModal = ({ isOpen, path, onClose }) => {
const settingId = path?.setting_id || 0;
const {data: pathSetting, isLoading: isPathSettingLoading} = usePathSetting(settingId);
const createPathSetting = useCreatePathSetting();
const updatePath = useUpdatePath();
const [activeTab, setActiveTab] = useState("webhook");
const handleCreatePathSetting = () => {
createPathSetting.mutate({}, {
onSuccess: (res) => {
updatePath.mutate({id: path.id, data: {setting_id: res.id}});
}
});
};
return (
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Path Settings</DialogTitle>
</DialogHeader>
{settingId && isPathSettingLoading ? (
<div className="flex justify-center py-10">
<Spinner label="Loading settings" />
</div>
) : !pathSetting ? (
<Button
type="button"
onClick={handleCreatePathSetting}
>
<Plus className="h-4 w-4" /> Create Path Setting
</Button>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="webhook">Webhook</TabsTrigger>
<TabsTrigger value="template">Template</TabsTrigger>
</TabsList>
<TabsContent value="webhook">
<WebhookSettingPanel
pathSetting={pathSetting}
onClose={onClose}
/>
</TabsContent>
<TabsContent value="template">
<div></div>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);
};
export default PathSettingModal;

View File

@@ -1,10 +1,13 @@
/*src/components/MainNavigation.css*/
.main-navigation { .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;
} }

View File

@@ -1,87 +1,224 @@
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
<img src="/icons/logo.png" alt="Logo" style={{ height: "40px", marginRight: "10px" }} /> to="/"
Home className="group flex items-center gap-2.5 pr-3"
</Link>
<a
role="button"
className="navbar-burger"
aria-label="menu"
aria-expanded="false"
data-target="navbarBasicExample"
onClick={(e) => {
e.currentTarget.classList.toggle("is-active");
document
.getElementById("navbarBasicExample")
.classList.toggle("is-active");
}}
> >
<span aria-hidden="true"></span> <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
variant="outline"
size="sm"
className="gap-2"
>
<span className="grid h-5 w-5 place-items-center rounded-full bg-primary/15 font-mono text-[10px] font-bold text-primary">
{(user.profile.name || "?")
.charAt(0)
.toUpperCase()}
</span>
<span className="max-w-[10rem] truncate">
{user.profile.name} {user.profile.name}
</span> </span>
<button <ChevronDown className="h-3.5 w-3.5 opacity-60" />
className="button is-danger" </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} onClick={logout}
type="button" className="text-destructive focus:text-destructive [&_svg]:text-destructive"
> >
Logout <LogOut /> Logout
</button> </DropdownMenuItem>
</div> </DropdownMenuContent>
</div> </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)}
/>
</>
); );
}; };

View File

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

View File

@@ -8,6 +8,18 @@
margin-left: 1rem; 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;
@@ -47,3 +59,23 @@
align-items: center; align-items: center;
width: 100%; width: 100%;
} }
.move-forward {
background-color: #c0fff0 !important;
border-color: #c0fbc0 !important;
color: #b0ffa0 !important;
}
.move-forward:hover {
background-color: #a0ffa0;
border-color: #0afb0c;
}
.move-backward {
background-color: #a0a0ff !important;
border-color: #c0c0ff !important;
color: #0af0fc !important;
}
.move-backward:hover {
background-color: #a4a4f0;
border-color: #facaff;
}

View File

@@ -1,137 +1,224 @@
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 "./PathNode.css"; import { useDeletePath, useMovePath, useUpdatePath } from "../../utils/queries/path-queries";
import {useDeletePath, usePaths, useUpdatePath} from "../../utils/path-queries"; import { useIndexMarkdown, useMoveMarkdown } from "../../utils/queries/markdown-queries";
import {useIndexMarkdown, useMarkdownsByPath} from "../../utils/markdown-queries"; import MarkdownNode from "./MarkdownNode";
import PathSettingModal from "../Modals/PathSettingModal";
import { cn } from "../../lib/utils";
const iconBtn =
"grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary";
const PathNode = ({ path, isRoot = false }) => { const PathNode = ({ path, isRoot = false }) => {
const [isExpanded, setIsExpanded] = useState(isRoot); const [isPathSettingModalOpen, setIsPathSettingModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(path.name); const [newName, setNewName] = useState(path.name || "");
const expandedNodes = useSelector(state => state.navigation.expandedNodes);
const dispatch = useDispatch();
const isExpanded = isRoot || expandedNodes[path.id];
const { data: childPaths, isLoading: isChildLoading, error: childError } = usePaths(path.id);
const { data: markdowns, isLoading: isMarkdownLoading, error: markdownError } = useMarkdownsByPath(path.id);
const deletePath = useDeletePath(); const deletePath = useDeletePath();
const updatePath = useUpdatePath(); const updatePath = useUpdatePath();
const { data: indexMarkdown } = useIndexMarkdown(path.id);
const movePath = useMovePath();
const moveMarkdown = useMoveMarkdown();
const {data: indexMarkdown, isLoading: isIndexLoading, error: indexMarkdownError} = useIndexMarkdown(path.id); const expand = () => {
if (!isExpanded) dispatch(toggleNodeExpansion(path.id));
const toggleExpand = () => {
setIsExpanded(!isExpanded);
}; };
const toggleExpand = () => dispatch(toggleNodeExpansion(path.id));
const handleSave = () => { const handleSave = () => {
console.log(`handleSave ${path.id}`); updatePath.mutate({ id: path.id, data: { name: newName } }, {
updatePath.mutate({id: path.id, data: {name: newName}}, { onSuccess: () => setIsEditing(false),
onsuccess: () => setIsEditing(false), onError: () => alert("failed to update this path"),
onError: err => alert("failed to update this path"), });
})
}; };
const handleDelete = () => { const handleDelete = () => {
if(window.confirm("Are you sure?")) { if (window.confirm("Are you sure?")) {
deletePath.mutate(path.id, { deletePath.mutate(path.id, {
onError: err => alert("failed to delete this path"), onError: () => alert("failed to delete this path"),
}) });
} }
}; };
const handleEdit = () => { const handleEdit = () => setIsEditing(true);
setIsEditing(true);
const handleMovePath = (pth, direction) => {
movePath.mutate({ path: pth, direction }, {
onError: () => alert("failed to move this path"),
});
};
const handleMoveMarkdown = (md, direction) => {
moveMarkdown.mutate({ markdown: md, direction }, {
onError: () => alert("failed to move this markdown"),
});
}; };
const childPaths = path.children.filter(x => x.type === "path");
const sortedPaths = childPaths
? childPaths.slice().sort((a, b) => a.order.localeCompare(b.order))
: [];
const markdowns = path.children.filter(x => x.type === "markdown");
const sortedMarkdowns = markdowns
? markdowns.filter(md => md.title !== "index").sort((a, b) => a.order.localeCompare(b.order))
: [];
if (isRoot)
if(childError || markdownError){ return (
return <li>Error...</li>; <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 is-clickable field has-addons" onClick={isRoot ? undefined : toggleExpand}> <div className="group flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-accent/60">
{isEditing ? ( <button
type="button"
onClick={toggleExpand}
title={isExpanded ? "Collapse" : "Expand"}
className="flex shrink-0 items-center gap-1 rounded text-muted-foreground transition-colors hover:text-primary"
>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform",
isExpanded && "rotate-90"
)}
/>
{isExpanded ? (
<FolderOpen className="h-4 w-4 text-secondary" />
) : (
<Folder className="h-4 w-4 text-secondary/70" />
)}
</button>
<div className = "control has-icons-left"> {isEditing ? (
<input <input
className="input is-small path-edit-input" autoFocus
className="h-6 min-w-0 flex-1 rounded border border-input bg-background/60 px-1.5 text-xs text-foreground focus:border-primary/60 focus:outline-none"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/> />
</div> ) : indexMarkdown ? (
// Clicking the name navigates to the folder's index page
) : ( // AND expands the subtree (expanded state is global, so
<span className="path-name has-text-weight-bold control"> // the children stay visible after navigation).
{ <Link
indexMarkdown ? ( to={`/markdown/${indexMarkdown.id}`}
<Link to={`/markdown/${indexMarkdown.id}`} className="is-link"> onClick={expand}
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground transition-colors hover:text-primary"
>
{path.name} {path.name}
</Link> </Link>
) : ( ) : (
<a className="is-link">{path.name}</a> <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"]}> <PermissionGuard rolesRequired={["admin"]}>
<div className="field has-addons actions control is-justify-content-flex-end"> <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 ? ( {isEditing ? (
<p className="control">
<button <button
className="button is-small is-success" type="button"
className={iconBtn}
title="Save"
onClick={handleSave} onClick={handleSave}
type="button"
> >
<span className="icon"> <Check className="h-3.5 w-3.5 text-primary" />
<i className="fas fa-check"></i>
</span>
</button> </button>
</p>
) : ( ) : (
<p className="control">
<button <button
className="button is-small is-info"
onClick={handleEdit}
type="button" type="button"
className={iconBtn}
title="Rename"
onClick={handleEdit}
> >
<span className="icon"> <Pencil className="h-3.5 w-3.5" />
<i className="fas fa-pen"></i>
</span>
</button> </button>
</p>
)} )}
<p className="control">
<button <button
className="button is-danger is-small" type="button"
className={cn(iconBtn, "hover:text-destructive")}
title="Delete"
onClick={handleDelete} onClick={handleDelete}
type="button"> >
<span className="icon"> <Trash2 className="h-3.5 w-3.5" />
<i className="fas fa-trash"></i> </button>
</span> <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> </button>
</p>
</div> </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">
{ isChildLoading && <p>Loading...</p>} {sortedPaths.map((child) => (
{ childPaths.map((child) => ( <PathNode key={child.id} path={child} />
<PathNode
key={child.id}
path={child}
/>
))} ))}
{sortedMarkdowns.map((markdown) => (
{ markdowns.filter(md => md.title !== "index").map((markdown) => ( <MarkdownNode
<li key={markdown.id}> markdown={markdown}
<Link to={`/markdown/${markdown.id}`} className="is-link"> handleMoveMarkdown={handleMoveMarkdown}
{markdown.title} key={markdown.id}
</Link> />
</li>
))} ))}
</ul> </ul>
)} )}

View File

@@ -3,8 +3,9 @@
border-radius: 8px; border-radius: 8px;
padding: 1rem; padding: 1rem;
background-color: #f9f9f9; background-color: #f9f9f9;
height: 100%; height: 90vh;
overflow-y: auto; overflow-y: hidden;
min-width: 15vw;
} }
.menu-label { .menu-label {
@@ -15,6 +16,8 @@
.menu-list { .menu-list {
margin-left: 0; margin-left: 0;
height: 100%;
overflow-y: auto;
} }
.menu-list-item { .menu-list-item {
@@ -46,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%;
}

View File

@@ -1,62 +1,62 @@
import PermissionGuard from "../PermissionGuard"; import React, { useContext, useEffect } from 'react';
import PathNode from "./PathNode"; import { useSelector, useDispatch } from 'react-redux';
import "./SideNavigation.css"; import { setSelectedTab } from '../../store/navigationSlice';
import {useDeletePath, usePaths, useUpdatePath} from "../../utils/path-queries"; import { FolderTree, LayoutTemplate } from "lucide-react";
import React from 'react'; import TreeTab from "./SideTabs/TreeTab";
import TemplateTab from "./SideTabs/TemplateTab";
import { AuthContext } from "../../AuthProvider";
import { ScrollArea } from "../ui/scroll-area";
import { cn } from "../../lib/utils";
const SideNavigation = () => { const SideNavigation = () => {
const {data: paths, isLoading, error } = usePaths(1); const { roles } = useContext(AuthContext);
const deletePath = useDeletePath(); const selectedTab = useSelector(state => state.navigation.selectedTab);
const updatePath = useUpdatePath(); const dispatch = useDispatch();
const handleDelete = (id) => { const allTabs = [
if (window.confirm("Are you sure you want to delete this path?")){ { id: "tree", label: "Tree", icon: FolderTree, component: <TreeTab /> },
deletePath.mutate(id, { { id: "templates", label: "Templates", icon: LayoutTemplate, component: <TemplateTab /> },
onError: (err) => { ];
alert("Failed to delete path");
},
});
}
};
const handleSave = (id, newName) => { const visibleTabs = roles.includes("admin")
updatePath.mutate({ id, data: {name: newName }} , { ? allTabs
onError: (err) => { : allTabs.filter(tab => tab.id === "tree");
alert("Failed to update path");
}
});
};
if(isLoading){
return <aside className="menu"><p>Loading...</p></aside>;
}
if(error){ useEffect(() => {
return <aside className="menu"><p>Error...</p></aside>; if (!visibleTabs.find(tab => tab.id === selectedTab)) {
dispatch(setSelectedTab(visibleTabs[0]?.id || ""));
} }
}, [visibleTabs, selectedTab, dispatch]);
const current = visibleTabs.find(t => t.id === selectedTab);
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
key={tab.id}
type="button"
onClick={() => dispatch(setSelectedTab(tab.id))}
className={cn(
"inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 font-mono text-xs font-medium uppercase tracking-wide transition-all",
active
? "bg-primary/15 text-primary shadow-[0_0_12px_-4px_hsl(var(--primary)/0.6)]"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
> >
Create New Markdown <Icon className="h-3.5 w-3.5" />
</a> {tab.label}
</PermissionGuard> </button>
<ul className="menu-list"> );
{isLoading && <p>Loading...</p>} })}
{paths.map((path) => ( </div>
<PathNode <ScrollArea className="flex-1">
key={path.id} <div className="p-3">{current?.component}</div>
path={path} </ScrollArea>
isRoot={false}
onSave={handleSave}
onDelete={handleDelete}
/>
))}
</ul>
</aside> </aside>
); );
}; };

View File

@@ -0,0 +1,184 @@
import React, { useState } from "react";
import { useMarkdownTemplates } from "../../../utils/queries/markdown-template-queries";
import PermissionGuard from "../../PermissionGuard";
import { Link, useNavigate } from "react-router-dom";
import { Search, LayoutTemplate, Pencil, Braces } from "lucide-react";
import JsonSchemaModal from "../../Modals/JsonSchemaModal";
import { Input } from "../../ui/input";
import { Button } from "../../ui/button";
import { Spinner } from "../../ui/misc";
const TemplateTab = () => {
const { data: templates, isLoading, error } = useMarkdownTemplates();
const [keyword, setKeyword] = useState("");
const [selectedSchema, setSelectedSchema] = useState(null);
const navigate = useNavigate();
const filteredTemplates = templates?.filter(template =>
template.title.toLowerCase().includes(keyword.toLowerCase())
);
const handleTemplateClick = (templateId) => {
navigate(`/template/edit/${templateId}`);
};
const generateJsonSchema = (template) => {
const schema = {
type: "object",
properties: {},
$defs: {}
};
const generateTypeSchema = (param, defName) => {
switch (param.type.base_type) {
case "string":
return {
type: "string"
};
case "markdown":
return {
type: "string",
description: "Markdown content"
};
case "enum":
return {
type: "string",
enum: param.type.definition.enums
};
case "list":
if (param.type.extend_type.base_type === "string") {
return {
type: "array",
items: {
type: "string"
}
};
} else if (param.type.extend_type.base_type === "list" ||
param.type.extend_type.base_type === "template") {
const itemsDefName = `${defName}_items`;
schema.$defs[itemsDefName] = generateTypeSchema(
{ type: param.type.extend_type },
itemsDefName
);
return {
type: "array",
items: {
$ref: `#/$defs/${itemsDefName}`
}
};
} else {
return {
type: "array",
items: generateTypeSchema(
{ type: param.type.extend_type },
`${defName}_items`
)
};
}
case "template":
const nestedTemplate = templates.find(t => t.id === param.type.definition.template.id);
if (nestedTemplate) {
const nestedSchema = {
type: "object",
properties: {}
};
nestedTemplate.parameters.forEach(nestedParam => {
nestedSchema.properties[nestedParam.name] = generateTypeSchema(
nestedParam,
`${defName}_${nestedParam.name}`
);
});
return nestedSchema;
} else {
return {
type: "object",
properties: {}
};
}
default:
return {
type: "object",
properties: {}
};
}
};
template.parameters.forEach(param => {
const defName = `param_${param.name}`;
schema.properties[param.name] = generateTypeSchema(param, defName);
});
if (Object.keys(schema.$defs).length === 0) {
delete schema.$defs;
}
return schema;
};
if (isLoading) return <Spinner label="Loading templates" />;
if (error)
return (
<p className="font-mono text-xs text-destructive">
Error loading templates
</p>
);
return (
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8 text-xs"
type="text"
placeholder="Search templates…"
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<Button asChild size="sm" variant="outline" className="w-full">
<Link to="/template/create">
<LayoutTemplate className="h-4 w-4" /> New Template
</Link>
</Button>
</PermissionGuard>
<ul className="space-y-0.5">
{filteredTemplates?.map((template) => (
<li
key={template.id}
className="group flex items-center gap-1 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/60"
>
<LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-secondary/80" />
<span className="min-w-0 flex-1 truncate text-sm text-foreground/90">
{template.title}
</span>
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
title="Edit"
onClick={() => handleTemplateClick(template.id)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
title="JSON schema"
onClick={() => setSelectedSchema(generateJsonSchema(template))}
>
<Braces className="h-3.5 w-3.5" />
</button>
</div>
</li>
))}
</ul>
<JsonSchemaModal
isActive={selectedSchema !== null}
onClose={() => setSelectedSchema(null)}
schema={selectedSchema}
/>
</div>
);
};
export default TemplateTab;

View File

@@ -0,0 +1,100 @@
import PermissionGuard from "../../PermissionGuard";
import PathNode from "../PathNode";
import React from "react";
import { Link } from "react-router-dom";
import { Search, FilePlus2 } from "lucide-react";
import { useTree } from "../../../utils/queries/tree-queries";
import { useDeletePath, useUpdatePath } from "../../../utils/queries/path-queries";
import { Input } from "../../ui/input";
import { Button } from "../../ui/button";
import { Spinner } from "../../ui/misc";
const TreeTab = () => {
const { data: tree, isLoading, error } = useTree();
const deletePath = useDeletePath();
const updatePath = useUpdatePath();
const [keyword, setKeyword] = React.useState("");
const handleDelete = (id) => {
if (window.confirm("Are you sure you want to delete this path?")) {
deletePath.mutate(id, {
onError: () => alert("Failed to delete path"),
});
}
};
const filterTree = (t, k) => {
if (t === undefined) return undefined;
if (t.type === "path") {
if (t.name.includes(k)) return { ...t };
const filteredChildren = (t.children || [])
.map(c => filterTree(c, k))
.filter(Boolean);
if (filteredChildren.length > 0) {
return { ...t, children: filteredChildren };
}
} else if (t.type === "markdown") {
if (t.title.includes(k)) return { ...t };
}
return undefined;
};
const filteredTree = filterTree(tree, keyword);
const handleSave = (id, newName) => {
updatePath.mutate({ id, data: { name: newName } }, {
onError: () => alert("Failed to update path"),
});
};
if (isLoading) return <Spinner label="Loading tree" />;
if (error)
return (
<p className="font-mono text-xs text-destructive">
Error loading tree
</p>
);
return (
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8 text-xs"
type="text"
placeholder="Search…"
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<Button
asChild
size="sm"
variant="outline"
className="w-full"
>
<Link to="/markdown/create">
<FilePlus2 className="h-4 w-4" /> New Markdown
</Link>
</Button>
</PermissionGuard>
{!filteredTree || filteredTree.length === 0 ? (
<p className="px-1 py-6 text-center font-mono text-xs text-muted-foreground">
No result
</p>
) : (
<PathNode
key={1}
path={filteredTree}
isRoot={true}
onSave={handleSave}
onDelete={handleDelete}
/>
)}
</div>
);
};
export default TreeTab;

View File

@@ -1,39 +1,5 @@
.path-manager-body { /* 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;
} }

View File

@@ -1,13 +1,17 @@
import React, {useEffect, useState, useRef, useContext} from "react"; import React, {useEffect, useState, useRef, useContext} from "react";
import { useCreatePath, usePaths } from "../utils/path-queries"; import {useCreatePath, usePaths} from "../utils/queries/path-queries";
import { useQueryClient } from "react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import "./PathManager.css"; import "./PathManager.css";
import {fetch_} from "../utils/request-utils"; import {fetch_} from "../utils/request-utils";
import {ConfigContext} from "../ConfigProvider"; 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 [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [dropdownActive, setDropdownActive] = useState(false); const [dropdownActive, setDropdownActive] = useState(false);
@@ -18,15 +22,16 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
const createPath = useCreatePath(); const createPath = useCreatePath();
const config = useContext(ConfigContext).config; const config = useContext(ConfigContext).config;
const buildPath = async (pathId) => {
const buildFullPath = async (pathId) => {
const path = []; const path = [];
let current_id = pathId; let current_id = pathId;
while (current_id) { while (current_id) {
try { try {
const pathData = await queryClient.fetchQuery( const pathData = await queryClient.fetchQuery({
["path", current_id], queryKey: ["path", current_id],
() => fetch_(`${config.BACKEND_HOST}/api/path/${current_id}`) queryFn: () => fetch_(`${config.BACKEND_HOST}/api/path/${current_id}`)
); });
if (!pathData) break; if (!pathData) break;
path.unshift({ name: pathData.name, id: pathData.id }); path.unshift({ name: pathData.name, id: pathData.id });
current_id = pathData.parent_id; current_id = pathData.parent_id;
@@ -40,21 +45,21 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
const path = await buildPath(currentPathId); const path = await buildFullPath(currentPathId);
setCurrentPath(path); setCurrentFullPath(path);
}; };
init(); init();
}, [currentPathId, queryClient]); }, [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);
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);
onPathChange(subPath.id); onPathChange(subPath.id);
setSearchTerm(""); setSearchTerm("");
setDropdownActive(false); setDropdownActive(false);
@@ -70,8 +75,8 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
{ name: searchTerm.trim(), parent_id: currentPathId }, { name: searchTerm.trim(), parent_id: currentPathId },
{ {
onSuccess: (newDir) => { onSuccess: (newDir) => {
queryClient.setQueryData(["path", newDir.id], newDir); queryClient.setQueryData({queryKey: ["path", newDir.id]}, newDir);
queryClient.invalidateQueries(["paths", currentPathId]); queryClient.invalidateQueries({queryKey: ["paths", currentPathId]});
setSearchTerm(""); setSearchTerm("");
alert("Directory created successfully."); alert("Directory created successfully.");
}, },
@@ -103,28 +108,26 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
}; };
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}
@@ -133,41 +136,42 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
onBlur={handleInputBlur} onBlur={handleInputBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
</div>
<div className="control">
<button
className="button is-small is-primary"
onClick={handleAddDirectory}
disabled={isSubPathsLoading || !searchTerm.trim()}
type="button"
>
Create "{searchTerm}"
</button>
</div>
</div>
{dropdownActive && ( {dropdownActive && (
<div className="dropdown is-active"> <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">
<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>
)} )}
{isSubPathsLoading && <p>Loading...</p>} </div>
{subPathsError && <p>Error loading subdirectories.</p>} )}
</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>
); );

View File

@@ -0,0 +1,102 @@
import {
useCreateMarkdownPermissionSetting,
useMarkdownPermissionSetting,
useUpdateMarkdownPermissionSetting
} from "../../../utils/queries/markdown-permission-setting-queries";
import React, {useEffect, useState} from "react";
import { Plus, Save } from "lucide-react";
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries";
import { Button } from "../../ui/button";
import { Label } from "../../ui/input";
import { Spinner } from "../../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
const MarkdownPermissionSettingPanel = ({markdownSetting, onClose}) => {
const {data: setting, isFetching: settingIsFetching } = useMarkdownPermissionSetting(markdownSetting?.permission_setting_id);
const [permission, setPermission] = useState("");
const createMarkdownPermissionSetting = useCreateMarkdownPermissionSetting();
const updateMarkdownSetting = useUpdateMarkdownSetting();
const updateMarkdownPermissionSetting = useUpdateMarkdownPermissionSetting();
useEffect(() => {
if (setting && setting.permission !== undefined && setting.permission !== null) {
setPermission(setting.permission);
}
}, [setting]);
const handleCreatePermissionSetting = () => {
createMarkdownPermissionSetting.mutate({permission: null}, {
onSuccess: (data) => {
updateMarkdownSetting.mutate({
id: markdownSetting.id,
data: {
permission_setting_id: data.id,
}
});
}
});
};
const handleSaveMarkdownPermissionSetting = () => {
const permissionValue = permission === "" ? null : permission;
updateMarkdownPermissionSetting.mutate({
id: setting.id,
data: {
permission: permissionValue,
}
}, {
onSuccess: () => alert("Saved"),
onError: () => alert("Failed to save"),
});
onClose();
};
if (settingIsFetching) {
return (
<div className="flex justify-center py-6">
<Spinner label="Loading permission" />
</div>
);
}
return setting ? (
<div className="mt-4 space-y-4 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Permission Setting
</h4>
<div className="space-y-2">
<Label htmlFor="permission-select">Permission</Label>
<select
id="permission-select"
className={SELECT_CLASS}
value={permission}
onChange={(e) => setPermission(e.target.value)}
>
<option value="">(None)</option>
<option value="public">public</option>
<option value="protected">protected</option>
<option value="private">private</option>
</select>
</div>
<Button
type="button"
onClick={handleSaveMarkdownPermissionSetting}
>
<Save className="h-4 w-4" /> Save Permission Setting
</Button>
</div>
) : (
<Button
type="button"
onClick={handleCreatePermissionSetting}
>
<Plus className="h-4 w-4" /> Create Permission Setting
</Button>
);
};
export default MarkdownPermissionSettingPanel;

View File

@@ -0,0 +1,106 @@
import {
useMarkdownTemplate,
useMarkdownTemplates,
} from "../../../utils/queries/markdown-template-queries";
import {
useCreateMarkdownTemplateSetting,
useMarkdownTemplateSetting,
useUpdateMarkdownTemplateSetting
} from "../../../utils/queries/markdown-template-setting-queries";
import React, {useEffect, useState} from "react";
import { Plus, Save } from "lucide-react";
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries";
import { Button } from "../../ui/button";
import { Label } from "../../ui/input";
import { Spinner } from "../../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
const MarkdownTemplateSettingPanel = ({markdownSetting, onClose}) => {
const {data: setting, isFetching: settingIsFetching } = useMarkdownTemplateSetting(markdownSetting?.template_setting_id);
const {data: templates, isFetching: templatesAreFetching}=useMarkdownTemplates();
const {data: template, isFetching: templateIsFetching} = useMarkdownTemplate(setting?.template_id);
const [selectedTemplateId, setSelectedTemplateId] = useState(template?.id ?? undefined);
const createMarkdownTemplateSetting = useCreateMarkdownTemplateSetting();
const updateMarkdownSetting = useUpdateMarkdownSetting();
const updateMarkdownTemplateSetting = useUpdateMarkdownTemplateSetting();
const handleCreateTemplateSetting = () => {
createMarkdownTemplateSetting.mutate({}, {
onSuccess: (data) => {
updateMarkdownSetting.mutate({
id: markdownSetting.id,
data: {
template_setting_id: data.id,
}
});
}
});
};
const handleSaveMarkdownTemplateSetting = () => {
updateMarkdownTemplateSetting.mutate({
id: setting.id,
data: {
template_id: selectedTemplateId,
}
}, {
onSuccess: () => alert("Saved"),
onError: () => alert("Failed to save"),
});
onClose();
};
useEffect(() => {
if(template?.id && selectedTemplateId === undefined) {
setSelectedTemplateId(template?.id ?? undefined);
}
},[template, selectedTemplateId]);
if (settingIsFetching || templatesAreFetching || templatesAreFetching || templateIsFetching) {
return (
<div className="flex justify-center py-6">
<Spinner label="Loading template" />
</div>
);
}
return setting ? (
<div className="mt-4 space-y-4 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Template Setting
</h4>
<div className="space-y-2">
<Label htmlFor="use-template-select">Use Template</Label>
<select
id="use-template-select"
className={SELECT_CLASS}
value={selectedTemplateId}
onChange={(e) => {
setSelectedTemplateId(e.target.value);
}}
>
<option value="">(default)</option>
{templates.map((_template, index) => (
<option key={index} value={_template.id}>{_template.title}</option>
))}
</select>
</div>
<Button
type="button"
onClick={handleSaveMarkdownTemplateSetting}
>
<Save className="h-4 w-4" /> Save Template Setting
</Button>
</div>
) : (
<Button
type="button"
onClick={handleCreateTemplateSetting}
>
<Plus className="h-4 w-4" /> Create Template Setting
</Button>
);
};
export default MarkdownTemplateSettingPanel;

View File

@@ -0,0 +1,412 @@
import React, { useEffect, useState } from "react";
import {
useWebhookSetting,
useCreateWebhookSetting,
useUpdateWebhookSetting,
useWebhooks,
} from "../../../utils/queries/webhook-queries";
import {
useCreateWebhook,
useUpdateWebhook,
useDeleteWebhook,
} from "../../../utils/queries/webhook-queries";
import {useUpdatePathSetting} from "../../../utils/queries/path-setting-queries";
import { Plus, Save, Pencil, Trash2, Check } from "lucide-react";
import { Button } from "../../ui/button";
import { Input, Label } from "../../ui/input";
import { Spinner } from "../../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
const CheckboxRow = ({ checked, onChange, label }) => (
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
className="h-4 w-4 accent-primary"
checked={checked}
onChange={onChange}
/>
{label}
</label>
);
const WebhookSettingPanel = ({pathSetting, onClose}) => {
const {data: setting} = useWebhookSetting(pathSetting?.webhook_setting_id || 0);
const {data: webhooks, isLoading: isWebhooksLoading} = useWebhooks();
const createWebhookSetting = useCreateWebhookSetting();
const updateWebhookSetting = useUpdateWebhookSetting();
const createWebhook = useCreateWebhook();
const updateWebhook = useUpdateWebhook();
const deleteWebhook = useDeleteWebhook();
const updatePathSetting = useUpdatePathSetting();
const [enabled, setEnabled] = useState(false);
const [isRecursive, setIsRecursive] = useState(false);
const [onEvents, setOnEvents] = useState(0);
const [selectedUrl, setSelectedUrl] = useState("");
const [headerList, setHeaderList] = useState([]);
const [additionalHeaders, setAdditionalHeaders] = useState({});
const [isOnMarkdownCreated, setIsOnMarkdownCreated] = useState(false);
const [isOnMarkdownUpdated, setIsOnMarkdownUpdated] = useState(false);
const [isOnMarkdownDeleted, setIsOnMarkdownDeleted] = useState(false);
const [isOnPathCreated, setIsOnPathCreated] = useState(false);
const [isOnPathUpdated, setIsOnPathUpdated] = useState(false);
const [isOnPathDeleted, setIsOnPathDeleted] = useState(false);
const assignFromOnEvents = (bits) => {
setIsOnMarkdownCreated(!!(bits & 1));
setIsOnMarkdownUpdated(!!(bits & 2));
setIsOnMarkdownDeleted(!!(bits & 4));
setIsOnPathCreated(!!(bits & 8));
setIsOnPathUpdated(!!(bits & 16));
setIsOnPathDeleted(!!(bits & 32));
};
const handleTriggerEventsUpdate = (eventType, isChecked) => {
setOnEvents((prev) => {
let nextVal = prev;
switch (eventType) {
case "MARKDOWN_CREATED":
nextVal = isChecked ? nextVal | 1 : nextVal & ~1;
setIsOnMarkdownCreated(isChecked);
break;
case "MARKDOWN_UPDATED":
nextVal = isChecked ? nextVal | 2 : nextVal & ~2;
setIsOnMarkdownUpdated(isChecked);
break;
case "MARKDOWN_DELETED":
nextVal = isChecked ? nextVal | 4 : nextVal & ~4;
setIsOnMarkdownDeleted(isChecked);
break;
case "PATH_CREATED":
nextVal = isChecked ? nextVal | 8 : nextVal & ~8;
setIsOnPathCreated(isChecked);
break;
case "PATH_UPDATED":
nextVal = isChecked ? nextVal | 16 : nextVal & ~16;
setIsOnPathUpdated(isChecked);
break;
case "PATH_DELETED":
nextVal = isChecked ? nextVal | 32 : nextVal & ~32;
setIsOnPathUpdated(isChecked);
break;
default:
break;
}
return nextVal;
});
};
const handleCreateWebhookSetting = () => {
createWebhookSetting.mutate({}, {
onSuccess: (data) => {
updatePathSetting.mutate({
id: pathSetting.id,
data: {
webhook_setting_id: data.id
}
});
}
});
};
useEffect(() => {
if (setting && webhooks) {
setEnabled(setting.enabled);
setIsRecursive(setting.recursive);
setOnEvents(setting.on_events);
assignFromOnEvents(setting.on_events);
try {
const headers = setting.additional_header
? JSON.parse(setting.additional_header)
: {};
setAdditionalHeaders(headers);
setHeaderList(
Object.entries(headers).map(([k, v]) => ({ key: k, value: v }))
);
} catch (err) {
setAdditionalHeaders({});
setHeaderList([]);
}
const found = webhooks.find((wh) => wh.id === setting.webhook_id);
setSelectedUrl(found ? found.hook_url : "");
} else {
setEnabled(false);
setIsRecursive(false);
setOnEvents(0);
assignFromOnEvents(0);
setAdditionalHeaders({});
setHeaderList([]);
setSelectedUrl("");
}
}, [setting, webhooks]);
const handleAddHeader = () => {
setHeaderList([...headerList, { key: "", value: "" }]);
};
const handleHeaderChange = (index, field, val) => {
const updated = [...headerList];
updated[index][field] = val;
setHeaderList(updated);
};
const handleApplyHeaders = () => {
const out = {};
headerList.forEach(({ key, value }) => {
if (key.trim()) out[key] = value;
});
setAdditionalHeaders(out);
};
const handleCreateWebhook = () => {
const newUrl = prompt("Enter the new webhook URL");
if (!newUrl) return;
createWebhook.mutate(newUrl, {
onSuccess: () => alert("Created new Webhook successfully"),
onError: () => alert("Failed to create new Webhook"),
});
};
const handleUpdateWebhook = () => {
if (!setting || !setting.webhook_id) {
alert("No webhook selected. Must pick from dropdown first.");
return;
}
const newUrl = prompt("Enter updated Webhook URL", selectedUrl);
if (!newUrl) return;
updateWebhook.mutate(
{ id: setting.webhook_id, data: { hook_url: newUrl } },
{
onSuccess: () => alert("Updated Webhook successfully"),
onError: () => alert("Failed to update Webhook"),
}
);
};
const handleDeleteWebhook = () => {
if (!setting || !setting.webhook_id) {
alert("No webhook selected to delete");
return;
}
if (!window.confirm("Are you sure?")) return;
deleteWebhook.mutate(setting.webhook_id, {
onSuccess: () => alert("Deleted Webhook successfully"),
onError: () => alert("Failed to delete Webhook"),
});
};
const handleSaveWebhookSetting = async () => {
const hook = webhooks.find((wh) => wh.hook_url === selectedUrl);
const payload = {
webhook_id: hook? hook.id : null,
recursive: isRecursive,
additional_header: JSON.stringify(additionalHeaders),
enabled,
on_events: onEvents,
};
if(!setting || !setting.id){
createWebhookSetting.mutate(payload, {
onSuccess: (res) => {
updatePathSetting.mutate({id: pathSetting.id, data: {webhook_setting_id: res.id}},{
onSuccess: () => alert("Webhook setting successfully created"),
onError: () => alert("Failed to save Webhook"),
})
},
onError: () => alert("Failed to save Webhook"),
});
} else {
updateWebhookSetting.mutate({id: setting.id, data: payload}, {
onSuccess: () => alert("Updated Webhook successfully"),
onError: () => alert("Failed to update Webhook"),
});
}
onClose();
};
return setting ? (
<div className="mt-4 space-y-5 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Webhook Setting
</h4>
<div className="space-y-2">
<Label>Select or Create a Webhook</Label>
<div className="flex items-center gap-2">
<div className="flex-1">
{isWebhooksLoading ? (
<Spinner label="Loading webhooks" />
) : (
<select
className={SELECT_CLASS}
value={selectedUrl}
onChange={(e) => setSelectedUrl(e.target.value)}
>
<option value="">(none)</option>
{webhooks.map((hook) => (
<option key={hook.id} value={hook.hook_url}>
{hook.hook_url}
</option>
))}
</select>
)}
</div>
<Button
type="button"
onClick={handleCreateWebhook}
>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{setting?.webhook_id && (
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleUpdateWebhook}
>
<Pencil className="h-4 w-4" /> Update Webhook URL
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleDeleteWebhook}
>
<Trash2 className="h-4 w-4" /> Delete Webhook
</Button>
</div>
)}
</div>
<CheckboxRow
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
label="Enabled"
/>
<div className="space-y-2">
<Label>On Events</Label>
<div className="grid grid-cols-1 gap-3 rounded-md border border-border bg-background/40 p-4 sm:grid-cols-2">
<div className="space-y-2">
<CheckboxRow
checked={isOnMarkdownCreated}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked)
}
label="Markdown Created"
/>
<CheckboxRow
checked={isOnMarkdownUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked)
}
label="Markdown Updated"
/>
<CheckboxRow
checked={isOnMarkdownDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked)
}
label="Markdown Deleted"
/>
</div>
<div className="space-y-2">
<CheckboxRow
checked={isOnPathCreated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_CREATED", e.target.checked)
}
label="Path Created"
/>
<CheckboxRow
checked={isOnPathUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
}
label="Path Updated"
/>
<CheckboxRow
checked={isOnPathDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
}
label="Path Deleted"
/>
</div>
</div>
</div>
<CheckboxRow
checked={isRecursive}
onChange={(e) => setIsRecursive(e.target.checked)}
label="Recursive"
/>
<div className="space-y-2">
<Label>Additional Headers</Label>
<div className="space-y-3 rounded-md border border-border bg-background/40 p-4">
{headerList.map((h, idx) => (
<div className="flex gap-2" key={idx}>
<Input
type="text"
placeholder="key"
value={h.key}
onChange={(e) => handleHeaderChange(idx, "key", e.target.value)}
/>
<Input
type="text"
placeholder="value"
value={h.value}
onChange={(e) =>
handleHeaderChange(idx, "value", e.target.value)
}
/>
</div>
))}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddHeader}
>
<Plus className="h-4 w-4" /> Header
</Button>
<Button
type="button"
size="sm"
onClick={handleApplyHeaders}
>
<Check className="h-4 w-4" /> Apply
</Button>
</div>
</div>
</div>
<Button
type="button"
onClick={handleSaveWebhookSetting}
>
<Save className="h-4 w-4" /> Save Webhook Setting
</Button>
</div>
) : (
<Button
type="button"
onClick={handleCreateWebhookSetting}
>
<Plus className="h-4 w-4" /> Create Webhook Setting
</Button>
);
}
export default WebhookSettingPanel

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

126
src/globals.css Normal file
View File

@@ -0,0 +1,126 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Lab-terminal theme — adapted from the Gitea STYLE.md for this React app.
Single acid accent (#d8ff3e), dark blueprint surfaces, monospace UI.
HSL triplets so Tailwind / shadcn-style primitives resolve hsl(var(--x)). */
:root,
.dark {
--background: 216 24% 4%; /* #080a0d ink */
--foreground: 217 35% 86%; /* #cdd8e8 text */
--surface: 214 29% 8%; /* #0f141b panel */
--card: 214 29% 8%;
--card-foreground: 217 38% 95%; /* #e9f0fa strong text */
--muted: 214 27% 12%; /* hovered panel */
--muted-foreground: 216 18% 48%; /* #647691 dim / eyebrow */
--accent: 214 27% 11%; /* row/element hover wash base */
--accent-foreground: 217 35% 86%;
--border: 213 28% 16%; /* #1d2733 lines / dividers */
--input: 213 28% 14%;
--ring: 72 100% 62%; /* acid focus ring */
/* THE accent — one acid, used for links/primary/active/focus. */
--primary: 72 100% 62%; /* #d8ff3e */
--primary-foreground: 96 24% 5%; /* #0c0f08 text on acid */
/* No second hue (STYLE: "one acid"). Secondary == acid so any
component using it stays on-brand. */
--secondary: 72 100% 62%;
--secondary-foreground: 96 24% 5%;
--destructive: 3 100% 66%; /* #ff5a52 errors only */
--destructive-foreground: 0 0% 100%;
--radius: 0.75rem;
}
* {
border-color: hsl(var(--border));
}
html,
body,
#root {
height: 100%;
}
html {
background-color: hsl(var(--background));
/* Larger base so all rem-based UI text scales up. */
font-size: 18px;
}
body {
margin: 0;
background-color: transparent;
color: hsl(var(--foreground));
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Site-wide blueprint grid backdrop (STYLE §11.2): fixed, behind content,
48px #1d2733 lines at low opacity, radial-masked, plus one acid sheen. */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-color: hsl(var(--background));
background-image:
radial-gradient(1000px 560px at 78% -8%, hsl(72 100% 62% / 0.10), transparent 60%),
linear-gradient(hsl(213 28% 16% / 0.16) 1px, transparent 1px),
linear-gradient(90deg, hsl(213 28% 16% / 0.16) 1px, transparent 1px);
background-size: 100% 100%, 48px 48px, 48px 48px;
-webkit-mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, #000 55%, transparent 100%);
mask-image: radial-gradient(ellipse 90% 80% at 50% 30%, #000 55%, transparent 100%);
}
::selection {
background: hsl(var(--primary) / 0.3);
color: hsl(var(--foreground));
}
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
*::-webkit-scrollbar {
width: 9px;
height: 9px;
}
*::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 999px;
}
*::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
@layer utilities {
/* Display lockup font — always lowercase per STYLE §3. */
.font-display {
font-family: "Major Mono Display", "IBM Plex Mono", monospace;
text-transform: lowercase;
}
.neon-text {
color: hsl(var(--primary));
text-shadow: 0 0 44px hsl(var(--primary) / 0.38);
}
/* kept for compatibility with components that used the old violet
accent — now the same single acid. */
.neon-text-violet {
color: hsl(var(--primary));
text-shadow: 0 0 44px hsl(var(--primary) / 0.38);
}
.glass {
background: hsl(214 29% 8% / 0.55);
backdrop-filter: blur(16px) saturate(150%);
border: 1px solid hsl(var(--primary) / 0.16);
}
}

View File

@@ -3,15 +3,17 @@ import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import AuthProvider, {AuthContext} from "./AuthProvider"; import AuthProvider, {AuthContext} from "./AuthProvider";
import "bulma/css/bulma.min.css"; import "bulma/css/bulma.min.css";
import {QueryClient, QueryClientProvider} from "react-query" import "./globals.css";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"
import ConfigProvider from "./ConfigProvider"; import ConfigProvider from "./ConfigProvider";
import ControlledReactQueryDevtools from "./components/Debug/ControlledReactQueryDevtools";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
retry: 2, retry: 2,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
staleTimeout: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
onError: (error) => { onError: (error) => {
if (error.message === "Unauthorized"){ if (error.message === "Unauthorized"){
const {logout} = queryClient const {logout} = queryClient
@@ -38,7 +40,7 @@ const EnhancedAuthProvider = ({children}) => {
}; };
React.useEffect(() => { React.useEffect(() => {
queryClient.setQueryDefaults("auths", { queryClient.setQueryDefaults(["auths"], {
context: {logout} context: {logout}
}); });
}, [logout]); }, [logout]);
@@ -52,6 +54,7 @@ root.render(
<AuthProvider> <AuthProvider>
<EnhancedAuthProvider> <EnhancedAuthProvider>
<App /> <App />
<ControlledReactQueryDevtools />
</EnhancedAuthProvider> </EnhancedAuthProvider>
</AuthProvider> </AuthProvider>
</QueryClientProvider> </QueryClientProvider>

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

@@ -0,0 +1,21 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional + Tailwind classes (shadcn convention). */
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
/** Format a backend datetime string for display; '—' when missing/invalid. */
export function formatDateTime(value) {
if (!value) return "—";
const d = new Date(value);
if (isNaN(d.getTime())) return "—";
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

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

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

View File

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

View File

@@ -1,77 +0,0 @@
import {useQuery, useMutation, useQueryClient} from 'react-query';
import {fetch_} from "./request-utils";
import {useConfig} from "../ConfigProvider";
export const useMarkdown = (id) => {
const config = useConfig();
return useQuery(
["markdown", id],
() => fetch_(`${config.BACKEND_HOST}/api/markdown/${id}`),
{
enabled: !!id,
});
};
export const useIndexMarkdown = (path_id) => {
const queryClient = useQueryClient();
const config = useConfig();
return useQuery(
["index_markdown", path_id],
() => fetch_(`${config.BACKEND_HOST}/api/markdown/get_index/${path_id}`),{
enabled: !!path_id,
onSuccess: (data) => {
if(data && data.id){
queryClient.setQueryData(["markdown", data.id], data);
}
}
});
};
export const useHomeMarkdown = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useQuery(
["home_markdown"],
() => fetch_(`${config.BACKEND_HOST}/api/markdown/get_home`), {
onSuccess: (data) => {
if (data && data.id){
queryClient.setQueryData(["markdown", data.id], data);
}
}
});
};
export const useMarkdownsByPath = (pathId) => {
const config = useConfig();
return useQuery(
["markdownsByPath", pathId],
() => fetch_(`${config.BACKEND_HOST}/api/markdown/by_path/${pathId}`),
{
enabled: !!pathId
});
};
export const useSaveMarkdown = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useMutation(({id, data}) => {
const url = id
? `${config.BACKEND_HOST}/api/markdown/${id}`
: `${config.BACKEND_HOST}/api/markdown/`;
const method = id ? "PUT" : "POST";
return fetch_(url, {
method,
body: JSON.stringify(data),
})
},{
onSuccess: (res, variables) => {
queryClient.invalidateQueries(["markdownsByPath", variables.data.parent_id]);
queryClient.invalidateQueries(["markdown", variables.data.id]);
},
});
};

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More