16 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
86 changed files with 5139 additions and 1737 deletions

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}"
DEBUG="${DEBUG:false}" # 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

View File

@@ -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

1922
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,20 @@
"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", "@reduxjs/toolkit": "^2.7.0",
"@tanstack/react-query": "^5.75.5", "@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-query-devtools": "^5.75.5",
"assert": "^2.1.0", "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",
@@ -32,8 +42,10 @@
"redux": "^5.0.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-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": {
@@ -43,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",
@@ -56,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,21 +1,4 @@
.app-container { /* Layout now lives in App.js via Tailwind; keep body from double-scrolling. */
display: flex;
flex-direction: column;
height: 100vh;
}
.content-container {
display: flex;
flex: 1;
overflow: hidden;
}
.main-content {
flex: 1;
padding: 1rem 1rem 100px 1rem;
overflow-y: auto;
}
:root { :root {
overflow: hidden; overflow: hidden;
} }

View File

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

View File

@@ -1,82 +1,61 @@
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 <div
className="toggle-button" className={cn(
onClick={toggleExpand} "overflow-hidden transition-all duration-300",
type="button" open ? "max-h-20" : "max-h-0"
)}
> >
{isExpanded ? "↓" : "↑"} <div className="flex items-center justify-center gap-6 py-3 text-xs">
</button> <a
<div className={`footer-content`}> href="https://www.linkedin.com/in/zhhrozhh/"
<p>&copy; {new Date().getFullYear()} Hangman Lab. {!isVisible && (<span> target="_blank"
&nbsp;&nbsp;&nbsp;&nbsp; rel="noopener noreferrer"
<a href="mailto:hzhang@hangman-lab.top">email</a> className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
&nbsp;&nbsp;&nbsp;&nbsp; >
<a href="https://git.hangman-lab.top/hzhang/HangmanLab">git</a> <Linkedin className="h-3.5 w-3.5" /> LinkedIn
&nbsp;&nbsp;&nbsp;&nbsp; </a>
&nbsp;&nbsp;&nbsp;&nbsp; <a
</span> href="https://git.hangman-lab.top/hzhang/HangmanLab"
)}</p> target="_blank"
{ rel="noopener noreferrer"
isVisible && ( className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
<div >
className={`footer-details ${isExpanded ? "expanded" : ""}`} <GitBranch className="h-3.5 w-3.5" /> Git
onTransitionEnd={onTransitionEnd} </a>
> <a
<div className="footer-icons"> href="mailto:hzhang@hangman-lab.top"
<a className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
href="https://www.linkedin.com/in/zhhrozhh/" >
target="_blank" <Mail className="h-3.5 w-3.5" /> Email
rel="noopener noreferrer" </a>
> </div>
<img </div>
src="/icons/linkedin.png" <div className="flex h-9 items-center justify-between px-4">
alt="LinkedIn" <p className="font-mono text-[11px] text-muted-foreground">
className="footer-icon" © {new Date().getFullYear()}{" "}
/> <span className="text-foreground/70">Hangman Lab</span>
LinkedIn </p>
</a> <button
<a type="button"
href="https://git.hangman-lab.top/hzhang/HangmanLab" onClick={() => setOpen((p) => !p)}
target="_blank" 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"
rel="noopener noreferrer" >
> <ChevronUp
<img className={cn(
src="/icons/git.png" "h-3.5 w-3.5 transition-transform",
alt="Git" open && "rotate-180"
className="footer-icon" )}
/> />
Git {open ? "less" : "more"}
</a> </button>
<a href="mailto:hzhang@hangman-lab.top">
<img
src="/icons/email.png"
alt="Email"
className="footer-icon"
/>
Email
</a>
</div>
</div>
)
}
</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

@@ -1,63 +1,55 @@
import React, { useState } from "react"; 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 EnumsEditor = ({ enums, onChange }) => {
const [_enums, setEnums] = useState(enums || []); const [_enums, setEnums] = useState(enums || []);
return ( return (
<div className="box"> <div className="space-y-3 rounded-md border border-border bg-background/40 p-4">
<ul> <ul className="space-y-2">
{_enums.map((item, index) => ( {_enums.map((item, index) => (
<li key={index} className="field has-addons" style={{ marginBottom: "0.5rem" }}> <li key={index} className="flex items-center gap-2">
<div className="control is-expanded"> <Input
<input className="h-8 text-xs"
className="input is-small" type="text"
type="text" value={item}
value={item} onChange={(e) => {
onChange={(e) => { const updated = [..._enums];
const updated = [..._enums]; updated[index] = e.target.value;
updated[index] = e.target.value; setEnums(updated);
setEnums(updated); onChange(updated);
onChange(updated); }}
}} />
/> <Button
</div> type="button"
<div className="control"> variant="destructive"
<button size="icon-sm"
className="button is-small is-danger" onClick={() => {
type="button" const updated = [..._enums];
onClick={() => { updated.splice(index, 1);
const updated = [..._enums]; setEnums(updated);
updated.splice(index, 1); onChange(updated);
setEnums(updated); }}
onChange(updated); >
}} <X className="h-3.5 w-3.5" />
> </Button>
<span className="icon is-small">
<i className="fas fa-times" />
</span>
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
<div className="field"> <Button
<div className="control"> type="button"
<button variant="outline"
className="button is-small is-primary" size="sm"
type="button" onClick={() => {
onClick={() => { const updated = [..._enums, ""];
const updated = [..._enums, ""]; setEnums(updated);
setEnums(updated); onChange(updated);
onChange(updated); }}
}} >
> <Plus className="h-4 w-4" /> Add Enum
<span className="icon is-small"> </Button>
<i className="fas fa-plus" />
</span>
<span>Add Enum</span>
</button>
</div>
</div>
</div> </div>
); );
}; };

View File

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

View File

@@ -1,10 +1,13 @@
import React, {useContext, useEffect, useState} from "react"; import React, {useContext, useEffect, useState} from "react";
import { AuthContext } from "../../AuthProvider"; import { AuthContext } from "../../AuthProvider";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { Save } from "lucide-react";
import { useMarkdownTemplate, useSaveMarkdownTemplate } from "../../utils/queries/markdown-template-queries"; import { useMarkdownTemplate, useSaveMarkdownTemplate } from "../../utils/queries/markdown-template-queries";
import LayoutEditor from "./LayoutEditor"; import LayoutEditor from "./LayoutEditor";
import ParametersManager from "./ParametersManager"; import ParametersManager from "./ParametersManager";
import "bulma/css/bulma.min.css"; import { Input, Label } from "../ui/input";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
const MarkdownTemplateEditor = () => { const MarkdownTemplateEditor = () => {
@@ -27,11 +30,19 @@ const MarkdownTemplateEditor = () => {
}, [template]); }, [template]);
if (templateIsFetching) { if (templateIsFetching) {
return <p>Loading...</p>; return (
<div className="flex justify-center py-20">
<Spinner label="Loading template" />
</div>
);
} }
if (!roles.includes("admin") || roles.includes("creator")) if (!roles.includes("admin") || roles.includes("creator"))
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>
);
const handleSave = () => { const handleSave = () => {
saveMarkdownTemplate.mutate( saveMarkdownTemplate.mutate(
{ id, data: { title, parameters, layout } }, { id, data: { title, parameters, layout } },
@@ -47,51 +58,48 @@ const MarkdownTemplateEditor = () => {
}; };
return ( return (
<section className="section"> <section className="mx-auto max-w-6xl px-6 py-8">
<div className="container"> <h2 className="mb-6 font-mono text-2xl font-bold tracking-tight text-foreground">
<h2 className="title is-4">Markdown Template Editor</h2> Markdown Template Editor
<div className="field"> </h2>
<label className="label">Title:</label> <div className="mb-6 space-y-2">
<div className="control"> <Label htmlFor="template-title">Title</Label>
<input <Input
className="input" id="template-title"
type="text" type="text"
placeholder="Enter template title" placeholder="Enter template title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
</div> </div>
</div> <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div className="columns is-variable is-8"> <div className="space-y-3">
<div className="column"> <h3 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
<h3 className="title is-5">Layout</h3> Layout
</h3>
<div className="box"> <div className="rounded-lg border border-border bg-card p-4">
<LayoutEditor <LayoutEditor
layout={layout} layout={layout}
parameters={parameters}
onChange={(newLayout) => setLayout(newLayout)}
/>
</div>
</div>
<div className="column">
<h3 className="title is-5">Parameters</h3>
<ParametersManager
parameters={parameters} parameters={parameters}
onChange={(newParameters) => setParameters(newParameters)} onChange={(newLayout) => setLayout(newLayout)}
/> />
</div> </div>
</div> </div>
<div className="field is-grouped"> <div className="space-y-3">
<div className="control"> <h3 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
<button className="button is-primary" onClick={handleSave}> Parameters
Save Template </h3>
</button> <ParametersManager
</div> parameters={parameters}
onChange={(newParameters) => setParameters(newParameters)}
/>
</div> </div>
</div> </div>
<div className="mt-6">
<Button onClick={handleSave}>
<Save className="h-4 w-4" /> Save Template
</Button>
</div>
</section> </section>
); );
}; };

View File

@@ -1,5 +1,8 @@
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import TypeEditor from "./TypeEditor"; import TypeEditor from "./TypeEditor";
import { Input, Label } from "../ui/input";
import { Button } from "../ui/button";
const ParametersManager = ({ parameters, onChange }) => { const ParametersManager = ({ parameters, onChange }) => {
const [_parameters, setParameters] = useState(parameters || []); const [_parameters, setParameters] = useState(parameters || []);
@@ -10,7 +13,7 @@ const ParametersManager = ({ parameters, onChange }) => {
..._parameters, ..._parameters,
{ {
name: "", name: "",
type: { type: {
base_type: "string", base_type: "string",
definition: {} definition: {}
} }
@@ -59,54 +62,53 @@ const ParametersManager = ({ parameters, onChange }) => {
}; };
return ( return (
<div className="box"> <div className="space-y-4 rounded-lg border border-border bg-card p-5">
<div className="field"> <Button type="button" onClick={handleAdd}>
<div className="control"> <Plus className="h-4 w-4" /> Add Parameter
<button className="button is-primary" onClick={handleAdd}> </Button>
Add Parameter <div className="max-h-[50vh] space-y-3 overflow-y-auto">
</button>
</div>
</div>
<div style={{ maxHeight: "50vh", overflowY: "auto" }}>
{_parameters.map((param, index) => ( {_parameters.map((param, index) => (
<div key={index} className="box" style={{ marginBottom: "0.5rem" }}> <div key={index} className="space-y-3 rounded-md border border-border bg-surface/40 p-4">
<div className="field is-grouped is-grouped-multiline"> <div className="flex items-end gap-2">
<div className="control is-expanded"> <div className="flex-1 space-y-1.5">
<label className="label mb-1">Name:</label> <Label>Name</Label>
<input <Input
type="text" type="text"
className="input"
value={param.name} value={param.name}
onChange={(e) => handleNameChange(index, e.target.value)} onChange={(e) => handleNameChange(index, e.target.value)}
placeholder="Parameter name" placeholder="Parameter name"
/> />
</div> </div>
<div className="control"> <Button
<button type="button"
className="button is-danger" variant="destructive"
onClick={() => handleDelete(index)} size="icon"
> onClick={() => handleDelete(index)}
Delete >
</button> <Trash2 className="h-4 w-4" />
</div> </Button>
</div> </div>
<div className="field"> <div className="space-y-2">
<div className="is-flex is-justify-content-space-between is-align-items-center mb-1"> <div className="flex items-center justify-between">
<label className="label mb-0">Type:</label> <Label>Type</Label>
<button <Button
className="button is-small" type="button"
variant="ghost"
size="icon-sm"
onClick={() => toggleExpand(index)} onClick={() => toggleExpand(index)}
> >
{expandedStates[index] ? "-" : "+"} {expandedStates[index] ? (
</button> <ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div> </div>
{expandedStates[index] && ( {expandedStates[index] && (
<div className="control"> <TypeEditor
<TypeEditor type={param.type}
type={param.type} onChange={(newType) => handleTypeChange(index, newType)}
onChange={(newType) => handleTypeChange(index, newType)} />
/>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,10 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useMarkdownTemplates } from "../../utils/queries/markdown-template-queries"; 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 TemplateSelector = ({ template, onChange, onCreate }) => {
const { data: templates, isFetching: templatesAreFetching } = useMarkdownTemplates(); const { data: templates, isFetching: templatesAreFetching } = useMarkdownTemplates();
@@ -22,38 +27,36 @@ const TemplateSelector = ({ template, onChange, onCreate }) => {
}, [template, templates]); }, [template, templates]);
if (templatesAreFetching) { if (templatesAreFetching) {
return <p>Loading...</p>; return <Spinner label="Loading templates" />;
} }
return ( return (
<div className="field"> <div className="space-y-2">
<label className="label">Select Template</label> <Label htmlFor="template-selector">Select Template</Label>
<div className="control"> <select
<div className="select is-fullwidth is-primary"> id="template-selector"
<select className={SELECT_CLASS}
value={template?.id || ""} value={template?.id || ""}
onChange={(e) => { onChange={(e) => {
const id = parseInt(e.target.value, 10); const id = parseInt(e.target.value, 10);
const selectedTemplate = templates.find((t) => t.id === id) || { const selectedTemplate = templates.find((t) => t.id === id) || {
title: "", title: "",
parameters: [], parameters: [],
layout: "", layout: "",
}; };
onChange(selectedTemplate); onChange(selectedTemplate);
if (onCreate) { if (onCreate) {
onCreate(selectedTemplate); onCreate(selectedTemplate);
} }
}} }}
> >
<option value="">(None)</option> <option value="">(None)</option>
{templates.map((tmpl) => ( {templates.map((tmpl) => (
<option key={tmpl.id} value={tmpl.id}> <option key={tmpl.id} value={tmpl.id}>
{tmpl.title} {tmpl.title}
</option> </option>
))} ))}
</select> </select>
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -1,6 +1,10 @@
import React from 'react'; import React from 'react';
import EnumsEditor from './EnumsEditor'; import EnumsEditor from './EnumsEditor';
import TemplateSelector from './TemplateSelector'; 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 TypeEditor = ({ type, onChange }) => {
const [_type, setType] = React.useState(type || {}); const [_type, setType] = React.useState(type || {});
@@ -14,71 +18,44 @@ const TypeEditor = ({ type, onChange }) => {
switch (_type.base_type) { switch (_type.base_type) {
case 'enum': case 'enum':
return ( return (
<div className="field"> <div className="space-y-2">
<label className="label">Enums</label> <Label>Enums</Label>
<div className="control"> <EnumsEditor
<EnumsEditor enums={_type.definition.enums}
enums={_type.definition.enums} onChange={(newEnums) => {
onChange={(newEnums) => { updateType({
updateType({ ..._type,
..._type, definition: { ..._type.definition, enums: newEnums },
definition: { ..._type.definition, enums: newEnums }, });
}); }}
}} />
/>
</div>
</div> </div>
); );
case 'list': case 'list':
return ( return (
<div className="box"> <div className="space-y-4 rounded-md border border-border bg-background/40 p-4">
<div className="field"> <div className="space-y-2">
<label className="label">Extend Type</label> <Label>Extend Type</Label>
<div className="control"> <TypeEditor
<TypeEditor type={_type.extend_type}
type={_type.extend_type} onChange={(extendType) => {
onChange={(extendType) => { updateType({ ..._type, extend_type: extendType });
updateType({ ..._type, extend_type: extendType }); }}
}} />
/>
</div>
</div> </div>
<div className="field"> <div className="space-y-2">
<label className="label">Iter Layout</label> <Label>Iter Layout</Label>
<div className="control"> <Textarea
<textarea className="font-mono text-sm"
className="textarea" value={_type.definition.iter_layout || ''}
value={_type.definition.iter_layout || ''} onChange={(e) => {
onChange={(e) => {
updateType({
..._type,
definition: {
..._type.definition,
iter_layout: e.target.value,
},
});
}}
/>
</div>
</div>
</div>
);
case 'template':
return (
<div className="field">
<label className="label">Template</label>
<div className="control">
<TemplateSelector
template={_type.definition.template}
onChange={(newTemplate) => {
updateType({ updateType({
..._type, ..._type,
definition: { definition: {
..._type.definition, ..._type.definition,
template: newTemplate, iter_layout: e.target.value,
}, },
}); });
}} }}
@@ -87,33 +64,43 @@ const TypeEditor = ({ type, onChange }) => {
</div> </div>
); );
case 'template':
return (
<TemplateSelector
template={_type.definition.template}
onChange={(newTemplate) => {
updateType({
..._type,
definition: {
..._type.definition,
template: newTemplate,
},
});
}}
/>
);
default: default:
return null; return null;
} }
}; };
return ( return (
<div className="box"> <div className="space-y-4 rounded-md border border-border bg-surface/40 p-4">
<div className="field"> <select
<label className="label">Type</label> className={SELECT_CLASS}
<div className="control"> value={_type.base_type || ''}
<div className="select is-fullwidth"> onChange={(e) => {
<select const updated = { base_type: e.target.value, definition: {} };
value={_type.base_type || ''} updateType(updated);
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="string">string</option> <option value="list">list</option>
<option value="markdown">markdown</option> <option value="template">template</option>
<option value="enum">enum</option> </select>
<option value="list">list</option>
<option value="template">template</option>
</select>
</div>
</div>
</div>
{renderExtraFields()} {renderExtraFields()}
</div> </div>
); );

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,18 +2,26 @@ 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/queries/markdown-queries"; import {useMarkdown} from "../../utils/queries/markdown-queries";
import {usePath} from "../../utils/queries/path-queries"; import {usePath} from "../../utils/queries/path-queries";
import {useMarkdownSetting} from "../../utils/queries/markdown-setting-queries"; import {useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries"; import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-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 { strId } = useParams(); const { strId } = useParams();
const id = Number(strId); 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: setting, isFetching: isSettingFetching} = useMarkdownSetting(markdown?.setting_id);
@@ -30,36 +38,79 @@ const MarkdownContent = () => {
const notReady = isLoading || isPathFetching || isSettingFetching || isTemplateSettingFetching || isTemplateFetching; const notReady = isLoading || isPathFetching || isSettingFetching || isTemplateSettingFetching || isTemplateFetching;
if (notReady) { if (notReady) {
return <div>Loading...</div>; 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) { if (markdown.isMessage) {
return ( return (
<div className="markdown-content-container"> <div className="rounded-lg border border-primary/30 bg-primary/10 px-5 py-4">
<div className="notification is-info"> <h4 className="mb-1 font-mono text-base font-semibold text-primary">
<h4 className="title is-4">{markdown.title}</h4> {markdown.title}
<p>{markdown.content}</p> </h4>
</div> <p className="text-sm text-foreground/80">{markdown.content}</p>
</div> </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
</Link> variant="outline"
size="sm"
onClick={() => setSettingModalOpen(true)}
>
<Settings2 className="h-4 w-4" /> Settings
</Button>
<Button asChild size="sm">
<Link to={`/markdown/edit/${id}`}>
<Pencil className="h-4 w-4" /> Edit
</Link>
</Button>
</div>
</PermissionGuard> </PermissionGuard>
</div> </div>
<MarkdownView content={JSON.parse(markdown.content)} template={template}/> <div className="-mt-3 mb-6 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted-foreground">
</div> <span className="inline-flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-secondary" />
{markdown.author || "—"}
</span>
<span className="inline-flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
created {formatDateTime(markdown.created_at)}
</span>
<span className="inline-flex items-center gap-1.5">
<History className="h-3.5 w-3.5" />
updated {formatDateTime(markdown.updated_at)}
{markdown.last_modified_by
? ` by ${markdown.last_modified_by}`
: ""}
</span>
</div>
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/>
<PatchCards markdownId={id} />
<MarkdownSettingModal
isOpen={isSettingModalOpen}
markdown={markdown}
onClose={() => setSettingModalOpen(false)}
/>
</article>
); );
}; };

View File

@@ -1,110 +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: 90vw; 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 { .raw-editor {
font-family: monospace;
white-space: pre; white-space: pre;
tab-size: 2; tab-size: 2;
} }
.editor-toggle-button {
margin-left: 10px;
}
.json-error {
color: red;
margin-top: 5px;
font-size: 0.9em;
}

View File

@@ -12,6 +12,10 @@ import TemplatedEditor from "./TemplatedEditor";
import {useMarkdownTemplateSetting, useUpdateMarkdownTemplateSetting, useCreateMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries"; import {useMarkdownTemplateSetting, useUpdateMarkdownTemplateSetting, useCreateMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
import TemplateSelector from "../MarkdownTemplate/TemplateSelector"; import TemplateSelector from "../MarkdownTemplate/TemplateSelector";
import {useCreateMarkdownSetting} from "../../utils/queries/markdown-setting-queries"; 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);
@@ -184,7 +188,11 @@ const MarkdownEditor = () => {
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) { if(notReady) {
console.log("============="); console.log("=============");
@@ -194,120 +202,130 @@ const MarkdownEditor = () => {
console.log("isTemplateSettingFetching", isTemplateSettingFetching); console.log("isTemplateSettingFetching", isTemplateSettingFetching);
console.log( "TemplatesAreFetching", templatesAreFetching); console.log( "TemplatesAreFetching", templatesAreFetching);
console.log("----------------"); console.log("----------------");
return <p>Loading...</p>; return (
<div className="flex justify-center py-20">
<Spinner label="Loading editor" />
</div>
);
} }
if(error) if(error)
return <p>{error.message || "Failed to load markdown"}</p>; return (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
{error.message || "Failed to load markdown"}
</div>
);
return ( return (
<div className="container mt-5 markdown-editor-container"> <div className="markdown-editor-container mx-auto max-w-[90vw] px-6 py-8">
<h2 className="title is-4">{id ? "Edit Markdown" : "Create Markdown"}</h2> <h2 className="mb-6 font-mono text-2xl font-bold tracking-tight text-foreground">
<div className="columns"> {id ? "Edit Markdown" : "Create Markdown"}
<div className="column is-half"> </h2>
<form> <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div className="field"> <div>
<label className="label">Title</label> <form className="space-y-5">
<div className="control"> <div className="space-y-2">
<input <Label htmlFor="md-title">Title</Label>
className="input" <Input
type="text" id="md-title"
placeholder="Enter title" type="text"
value={title} placeholder="Enter title"
onChange={(e) => setTitle(e.target.value)} value={title}
/> onChange={(e) => setTitle(e.target.value)}
</div> />
</div> </div>
<div className="field"> <div className="space-y-2">
<label className="label">Shortcut</label> <Label htmlFor="md-shortcut">Shortcut</Label>
<div className="control"> <Input
<input id="md-shortcut"
className="input" type="text"
type="text" placeholder="Enter shortcut"
placeholder="Enter shortcut" value={shortcut}
value={shortcut} onChange={(e) => setShortcut(e.target.value)}
onChange={(e) => setShortcut(e.target.value)} />
/>
</div>
</div> </div>
<div className="field"> <div className="space-y-2">
<label className="label">Path</label> <Label>Path</Label>
<PathManager <PathManager
currentPathId={pathId} currentPathId={pathId}
onPathChange={setPathId} onPathChange={setPathId}
/> />
</div> </div>
<div className="field"> <div>
<div className="control"> <TemplateSelector
<TemplateSelector template={selectedTemplate || template}
template={selectedTemplate || template} onChange={handleTemplateChange}
onChange={handleTemplateChange} />
/>
</div>
</div> </div>
<div className="field"> <div className="space-y-2">
<div className="is-flex is-justify-content-space-between is-align-items-center mb-2"> <div className="flex items-center justify-between">
<label className="label mb-0">Content</label> <Label>Content</Label>
<button <Button
type="button" type="button"
className={`button is-small editor-toggle-button ${isRawMode ? 'is-info' : 'is-light'}`} variant="outline"
size="sm"
onClick={toggleEditMode} onClick={toggleEditMode}
> >
{isRawMode ? 'Switch to Template Editor' : 'Switch to Raw Editor'} {isRawMode ? (
</button> <>
<LayoutTemplate className="h-4 w-4" /> Switch to Template Editor
</>
) : (
<>
<Code className="h-4 w-4" /> Switch to Raw Editor
</>
)}
</Button>
</div> </div>
<div className="control"> {isRawMode ? (
{isRawMode ? ( <div className="space-y-2">
<div> <p className="text-xs text-muted-foreground">
<p className="help mb-2"> Edit the JSON directly. Make sure it's valid JSON before saving.
Edit the JSON directly. Make sure it's valid JSON before saving. </p>
</p> <Textarea
<textarea className={`raw-editor h-[70vh] font-mono text-sm ${jsonError ? "border-destructive focus-visible:border-destructive" : ""}`}
className={`textarea raw-editor ${jsonError ? 'is-danger' : ''}`} value={rawContent}
style={{height: "70vh"}} onChange={handleRawContentChange}
value={rawContent} placeholder="Enter JSON content here"
onChange={handleRawContentChange}
placeholder="Enter JSON content here"
/>
{jsonError && (
<p className="help is-danger json-error">{jsonError}</p>
)}
</div>
) : (
<TemplatedEditor
style={{height: "40vh"}}
content={content}
template={!markdown?.id ? selectedTemplate : template}
onContentChanged={(k, v) => setContent(
prev => ({...prev, [k]: v})
)}
/> />
)} {jsonError && (
</div> <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>
<div className="field"> <div>
<div className="control"> <Button
<button type="button"
className="button is-primary" onClick={handleSave}
type="button" disabled={saveMarkdown.isLoading}
onClick={handleSave} >
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>
<div className="column is-half"> <div>
<h3 className="subtitle is-5">Preview</h3> <h3 className="mb-3 font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
<MarkdownView Preview
content={content} </h3>
template={!markdown?.id ? selectedTemplate : template} <MarkdownView
content={content}
template={!markdown?.id ? selectedTemplate : template}
height='70vh' height='70vh'
/> />
</div> </div>

View File

@@ -1,112 +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;
.markdown-preview h6 { box-shadow: inset 0 0 40px -20px hsl(var(--primary) / 0.25);
font-size: 1em; }
font-weight: bold; .markdown-preview pre code {
margin-bottom: 0.5rem; font-family: "IBM Plex Mono", ui-monospace, monospace;
color: #888; font-size: 0.85rem;
background: transparent;
border: none;
padding: 0;
} }
/* Tables */
.markdown-preview table { .markdown-preview table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
border: 1px solid #ddd; margin: 0 0 1rem;
font-size: 0.9rem;
} }
.markdown-preview th, .markdown-preview th,
.markdown-preview td { .markdown-preview td {
border: 1px solid #ddd; border: 1px solid hsl(var(--border));
padding: 8px; padding: 0.6rem 0.8rem;
text-align: left; text-align: left;
} }
.markdown-preview th { .markdown-preview th {
background-color: #f4f4f4; background-color: hsl(var(--muted));
font-weight: bold; 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,6 +3,7 @@ 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 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";
@@ -46,6 +47,21 @@ const ParseTemplate = ({template, variables}) => {
}; };
// 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 MarkdownView = ({ content, template, height="auto" }) => {
const {data: links, isLoading} = useLinks(); const {data: links, isLoading} = useLinks();
@@ -74,7 +90,7 @@ const MarkdownView = ({ content, template, height="auto" }) => {
variables: content variables: content
}) + "\n" + linkDefinitions} }) + "\n" + linkDefinitions}
remarkPlugins={[remarkMath, remarkGfm]} remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeKatex, rehypeRaw]} 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

@@ -1,5 +1,14 @@
import React, {useState} from "react"; import React, {useState} from "react";
import { Plus, Trash2 } from "lucide-react";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries"; 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 }) => { const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged }) => {
console.log("variable", variable); console.log("variable", variable);
@@ -9,50 +18,41 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
switch (variable.type.base_type) { switch (variable.type.base_type) {
case "string": case "string":
return ( return (
<div className="box has-background-danger-soft"> <div className={FIELD_BOX}>
<label className="label">{__namespace}</label> <Label>{__namespace}</Label>
<div className="control"> <Input
<input type="text"
type="text" value={value ?? ""}
className="input" onChange={(e) => onContentChanged(variable.name, e.target.value)}
value={value ?? ""} />
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
</div>
</div> </div>
); );
case "markdown": case "markdown":
return ( return (
<div className="box has-background-primary-soft"> <div className={FIELD_BOX}>
<label className="label">{__namespace}</label> <Label>{__namespace}</Label>
<div className="control"> <Textarea
<textarea className="max-h-[10vh] font-mono text-sm"
style={{maxHeight: "10vh"}} value={value}
className="textarea" onChange={(e) => onContentChanged(variable.name, e.target.value)}
value={value} />
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
</div>
</div> </div>
); );
case "enum": case "enum":
return ( return (
<div className="box has-background-info-soft"> <div className={FIELD_BOX}>
<label className="label">{__namespace}</label> <Label>{__namespace}</Label>
<div className="control"> <select
<div className="select is-fullwidth"> className={SELECT_CLASS}
<select value={value}
value={value} onChange={(e) => onContentChanged(variable.name, e.target.value)}
onChange={(e) => onContentChanged(variable.name, e.target.value)} >
> {variable.type.definition.enums.map((item) => (
{variable.type.definition.enums.map((item) => ( <option key={item} value={item}>{item}</option>
<option key={item} value={item}>{item}</option> ))}
))} </select>
</select>
</div>
</div>
</div> </div>
); );
@@ -80,11 +80,11 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
}; };
return ( return (
<div className="box has-background-white-soft"> <div className={FIELD_BOX}>
<label className="label">{__namespace}</label> <Label>{__namespace}</Label>
{cache.map((item, idx) => ( {cache.map((item, idx) => (
<div className="field is-grouped" key={idx}> <div className="flex items-start gap-2" key={idx}>
<div className="control is-expanded"> <div className="flex-1">
<TemplatedEditorComponent <TemplatedEditorComponent
variable={{ name: idx, type: variable.type.extend_type }} variable={{ name: idx, type: variable.type.extend_type }}
value={item} value={item}
@@ -92,35 +92,31 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
onContentChanged={(subKey, subVal) => onItemChange(idx, subVal)} onContentChanged={(subKey, subVal) => onItemChange(idx, subVal)}
/> />
</div> </div>
<div className="control"> <Button
<button type="button"
className="button is-danger" variant="destructive"
type="button" size="icon"
onClick={() => removeItem(idx)} onClick={() => removeItem(idx)}
> >
DELETE <Trash2 className="h-4 w-4" />
</button> </Button>
</div>
</div> </div>
))} ))}
<div className="field"> <Button
<div className="control"> type="button"
<button variant="outline"
className="button is-warning" size="sm"
type="button" onClick={addItem}
onClick={addItem} >
> <Plus className="h-4 w-4" /> Add
ADD </Button>
</button>
</div>
</div>
</div> </div>
); );
} }
case "template": { case "template": {
const { data: _template, isFetching: loading } = useMarkdownTemplate(variable.type.definition.template.id); const { data: _template, isFetching: loading } = useMarkdownTemplate(variable.type.definition.template.id);
if (loading) return <p>Loading...</p>; if (loading) return <Spinner label="Loading template" />;
const _parameters = _template.parameters; const _parameters = _template.parameters;
const handleSubChange = (key, val) => { const handleSubChange = (key, val) => {
@@ -129,10 +125,10 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
}; };
return ( return (
<div className="box has-background-grey-light"> <div className={FIELD_BOX}>
<label className="label">{__namespace}</label> <Label>{__namespace}</Label>
{_parameters.map((param, i) => ( {_parameters.map((param, i) => (
<div className="field" key={i}> <div key={i}>
<TemplatedEditorComponent <TemplatedEditorComponent
variable={param} variable={param}
value={(value || {})[param.name]} value={(value || {})[param.name]}
@@ -161,17 +157,11 @@ const TemplatedEditor = ({ content, template, onContentChanged, style }) => {
}; };
return ( return (
<div className="box" style={{ <div
...style, className="flex flex-col overflow-hidden rounded-lg border border-border bg-card"
display: "flex", style={style}
flexDirection: "column", >
overflow: "hidden" <div className="flex-1 space-y-4 overflow-y-auto p-4">
}}>
<div style={{
flex: 1,
overflowY: "auto",
padding: "1rem"
}}>
{tpl.parameters.map((variable, idx) => ( {tpl.parameters.map((variable, idx) => (
<TemplatedEditorComponent <TemplatedEditorComponent
key={idx} key={idx}
@@ -186,4 +176,4 @@ const TemplatedEditor = ({ content, template, onContentChanged, style }) => {
); );
}; };
export default TemplatedEditor; export default TemplatedEditor;

View File

@@ -1,11 +1,26 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plus, Trash2, Copy, KeyRound } from 'lucide-react';
import { useCreateApiKey } from '../../utils/queries/apikey-queries'; import { useCreateApiKey } from '../../utils/queries/apikey-queries';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Input, Label } from '../ui/input';
const AVAILABLE_ROLES = ['guest', 'creator', 'admin']; // 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 ApiKeyCreationModal = ({ isOpen, onClose }) => {
const [alias, setAlias] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [roles, setRoles] = useState(['guest']); const [roles, setRoles] = useState(['user']);
const [generatedKey, setGeneratedKey] = useState(null); const [generatedKey, setGeneratedKey] = useState(null);
const createApiKeyMutation = useCreateApiKey(); const createApiKeyMutation = useCreateApiKey();
@@ -31,12 +46,17 @@ const ApiKeyCreationModal = ({ isOpen, onClose }) => {
}; };
const handleGenerate = async () => { const handleGenerate = async () => {
if (!alias.trim()) {
alert('Alias is required');
return;
}
if (!name.trim()) { if (!name.trim()) {
alert('API key name is required'); alert('API key name is required');
return; return;
} }
try { try {
const result = await createApiKeyMutation.mutateAsync({ const result = await createApiKeyMutation.mutateAsync({
alias: alias.trim(),
name: name.trim(), name: name.trim(),
roles: roles roles: roles
}); });
@@ -48,117 +68,124 @@ const ApiKeyCreationModal = ({ isOpen, onClose }) => {
}; };
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText(generatedKey) navigator.clipboard.writeText(generatedKey.key)
.then(() => alert('API key copied to clipboard')) .then(() => alert('API key copied to clipboard'))
.catch(err => console.error('failed to copy api key:', err)); .catch(err => console.error('failed to copy api key:', err));
}; };
const getRemainingRoles = (currentIndex) => { const getRemainingRoles = (currentIndex) => {
return AVAILABLE_ROLES.filter(role => return AVAILABLE_ROLES.filter(role =>
!roles.find((r, i) => r === role && i !== currentIndex) !roles.find((r, i) => r === role && i !== currentIndex)
); );
}; };
if (!isOpen) return null;
return ( return (
<div className="modal is-active"> <Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<div className="modal-background" onClick={onClose}></div> <DialogContent>
<div className="modal-card"> <DialogHeader>
<header className="modal-card-head"> <DialogTitle>Create API Key</DialogTitle>
<p className="modal-card-title">Create API Key</p> </DialogHeader>
<button className="delete" aria-label="close" onClick={onClose}></button> {!generatedKey ? (
</header> <div className="space-y-5">
<section className="modal-card-body"> <div className="space-y-2">
{!generatedKey ? ( <Label htmlFor="api-key-alias">Alias</Label>
<div> <Input
<div className="field"> id="api-key-alias"
<label className="label">Name</label> type="text"
<div className="control"> placeholder="unique alias (reuse to renew)"
<input value={alias}
className="input" onChange={(e) => setAlias(e.target.value)}
type="text" />
placeholder="API key name" <p className="text-xs text-muted-foreground">
value={name} Unique. Using an existing alias renews that
onChange={(e) => setName(e.target.value)} key (same key string, validity reset).
/> </p>
</div> </div>
</div> <div className="space-y-2">
<div className="field"> <Label htmlFor="api-key-name">Name</Label>
<label className="label">Roles</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) => ( {roles.map((role, index) => (
<div key={index} className="field has-addons"> <div key={index} className="flex items-center gap-2">
<div className="control"> <select
<div className="select"> className={SELECT_CLASS}
<select value={role}
value={role} onChange={(e) => handleRoleChange(index, e.target.value)}
onChange={(e) => handleRoleChange(index, e.target.value)} >
> {getRemainingRoles(index).map(availableRole => (
{getRemainingRoles(index).map(availableRole => ( <option key={availableRole} value={availableRole}>
<option key={availableRole} value={availableRole}> {availableRole}
{availableRole} </option>
</option> ))}
))} </select>
</select> <Button
</div> variant="destructive"
</div> size="icon"
<div className="control"> onClick={() => handleRemoveRole(index)}
<button disabled={roles.length === 1}
className="button is-danger" >
onClick={() => handleRemoveRole(index)} <Trash2 className="h-4 w-4" />
disabled={roles.length === 1} </Button>
>
Delete
</button>
</div>
</div> </div>
))} ))}
<button
className="button is-info mt-2"
onClick={handleAddRole}
disabled={roles.length === AVAILABLE_ROLES.length}
>
Add Role
</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>
<div> ) : (
<div className="notification is-warning"> <div className="space-y-4">
Please copy your API key immediately! It will only be displayed once! <div className="rounded-lg border border-secondary/40 bg-secondary/10 px-4 py-3 font-mono text-sm text-secondary">
</div> {generatedKey.renewed
<div className="field"> ? "Key renewed — same key string, validity reset 15 days."
<label className="label">Your API Key:</label> : "Please copy your API key immediately! It will only be displayed once!"}
<div className="control">
<input
className="input"
type="text"
value={generatedKey.key}
readOnly
/>
</div>
</div>
<button className="button is-info" onClick={handleCopy}>
Copy
</button>
</div> </div>
)} <div className="space-y-2">
</section> <Label htmlFor="generated-api-key">Your API Key</Label>
<footer className="modal-card-foot"> <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 && ( {!generatedKey && (
<button <Button
className="button is-primary"
onClick={handleGenerate} onClick={handleGenerate}
disabled={createApiKeyMutation.isLoading || !name.trim()} disabled={createApiKeyMutation.isLoading || !alias.trim() || !name.trim()}
> >
Generate <KeyRound className="h-4 w-4" /> Generate
</button> </Button>
)} )}
<button className="button" onClick={onClose}>Close</button> </DialogFooter>
</footer> </DialogContent>
</div> </Dialog>
</div>
); );
}; };
export default ApiKeyCreationModal; export default ApiKeyCreationModal;

View File

@@ -1,5 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { useRevokeApiKey } from '../../utils/queries/apikey-queries'; 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 ApiKeyRevokeModal = ({ isOpen, onClose }) => {
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
@@ -21,43 +31,37 @@ const ApiKeyRevokeModal = ({ isOpen, onClose }) => {
} }
}; };
if (!isOpen) return null;
return ( return (
<div className="modal is-active"> <Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<div className="modal-background" onClick={onClose}></div> <DialogContent>
<div className="modal-card"> <DialogHeader>
<header className="modal-card-head"> <DialogTitle>Revoke API Key</DialogTitle>
<p className="modal-card-title">Revoke API Key</p> </DialogHeader>
<button className="delete" aria-label="close" onClick={onClose}></button> <div className="space-y-2">
</header> <Label htmlFor="revoke-api-key">API Key</Label>
<section className="modal-card-body"> <Input
<div className="field"> id="revoke-api-key"
<label className="label">API Key</label> type="text"
<div className="control"> placeholder="Enter API key to revoke"
<input value={apiKey}
className="input" onChange={(e) => setApiKey(e.target.value)}
type="text" />
placeholder="Enter API key to revoke" </div>
value={apiKey} <DialogFooter>
onChange={(e) => setApiKey(e.target.value)} <Button variant="outline" onClick={onClose}>
/> Cancel
</div> </Button>
</div> <Button
</section> variant="destructive"
<footer className="modal-card-foot">
<button
className="button is-danger"
onClick={handleRevoke} onClick={handleRevoke}
disabled={revokeApiKeyMutation.isLoading} disabled={revokeApiKeyMutation.isLoading}
> >
Revoke <Trash2 className="h-4 w-4" /> Revoke
</button> </Button>
<button className="button" onClick={onClose}>Cancel</button> </DialogFooter>
</footer> </DialogContent>
</div> </Dialog>
</div>
); );
}; };
export default ApiKeyRevokeModal; export default ApiKeyRevokeModal;

View File

@@ -1,4 +1,14 @@
import React from 'react'; 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 JsonSchemaModal = ({ isActive, onClose, schema }) => {
const handleCopy = () => { const handleCopy = () => {
@@ -6,37 +16,27 @@ const JsonSchemaModal = ({ isActive, onClose, schema }) => {
}; };
return ( return (
<div className={`modal ${isActive ? 'is-active' : ''}`}> <Dialog open={isActive} onOpenChange={(o) => { if (!o) onClose(); }}>
<div className="modal-background" onClick={onClose}></div> <DialogContent className="max-w-2xl">
<div className="modal-card"> <DialogHeader>
<header className="modal-card-head"> <DialogTitle>JSON Schema</DialogTitle>
<p className="modal-card-title">JSON Schema</p> </DialogHeader>
<button className="delete" aria-label="close" onClick={onClose}></button> <Textarea
</header> className="h-[50vh] font-mono text-xs"
<section className="modal-card-body"> value={JSON.stringify(schema, null, 2)}
<div className="field"> readOnly
<div className="control"> />
<textarea <DialogFooter>
className="textarea" <Button variant="outline" onClick={onClose}>
value={JSON.stringify(schema, null, 2)} Close
readOnly </Button>
style={{ height: "50vh" }} <Button onClick={handleCopy}>
/> <Copy className="h-4 w-4" /> Copy
</div> </Button>
</div> </DialogFooter>
</section> </DialogContent>
<footer className="modal-card-foot"> </Dialog>
<button className="button is-primary" onClick={handleCopy}>
<span className="icon">
<i className="fas fa-copy"></i>
</span>
<span>copy</span>
</button>
<button className="button" onClick={onClose}>close</button>
</footer>
</div>
</div>
); );
}; };
export default JsonSchemaModal; export default JsonSchemaModal;

View File

@@ -1,8 +1,18 @@
import {useCreateMarkdownSetting, useMarkdownSetting} from "../../utils/queries/markdown-setting-queries"; import {useCreateMarkdownSetting, useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
import {useSaveMarkdown} from "../../utils/queries/markdown-queries"; import {useSaveMarkdown} from "../../utils/queries/markdown-queries";
import React, {useState} from "react"; import React, {useState} from "react";
import { Plus } from "lucide-react";
import MarkdownTemplateSettingPanel from "../Settings/MarkdownSettings/MarkdownTemplateSettingPanel"; import MarkdownTemplateSettingPanel from "../Settings/MarkdownSettings/MarkdownTemplateSettingPanel";
import MarkdownPermissionSettingPanel from "../Settings/MarkdownSettings/MarkdownPermissionSettingPanel"; 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 MarkdownSettingModal = ({isOpen, markdown, onClose}) => {
const {data: markdownSetting, isFetching: markdownSettingIsFetching} = useMarkdownSetting(markdown?.setting_id || 0); const {data: markdownSetting, isFetching: markdownSettingIsFetching} = useMarkdownSetting(markdown?.setting_id || 0);
@@ -24,62 +34,45 @@ const MarkdownSettingModal = ({isOpen, markdown, onClose}) => {
}); });
}; };
if(markdownSettingIsFetching)
return(<p>Loading...</p>);
return ( return (
<div className={`modal ${isOpen ? "is-active" : ""}`}> <Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<div className="modal-background" onClick={onClose} /> <DialogContent className="max-w-3xl">
<div className="modal-card" style={{width: "60vw"}}> <DialogHeader>
<header className="modal-card-head"> <DialogTitle>Markdown Settings</DialogTitle>
<p className="modal-card-title">Markdown Settings</p> </DialogHeader>
<button {markdownSettingIsFetching ? (
className="delete" <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" type="button"
aria-label="close" onClick={handleCreateMarkdownSetting}
onClick={onClose} >
/> <Plus className="h-4 w-4" /> Create Markdown Setting
</header> </Button>
{ )}
markdownSetting ? ( </DialogContent>
<section className="modal-card-body"> </Dialog>
<div className="tabs">
<ul>
<li className={activeTab==="template" ? "is-active" : ""}>
<a onClick={() => setActiveTab("template")}>Template</a>
</li>
<li className={activeTab==="permission" ? "is-active" : ""}>
<a onClick={() => setActiveTab("permission")}>Permission</a>
</li>
</ul>
</div>
{activeTab === "template" && (
<MarkdownTemplateSettingPanel
markdownSetting={markdownSetting}
onClose={onClose}
/>
)}
{activeTab === "permission" && (
<MarkdownPermissionSettingPanel
markdownSetting={markdownSetting}
onClose={onClose}
/>
)}
</section>
) : (
<section className="modal-card-body">
<button
className="button is-primary"
type="button"
onClick={handleCreateMarkdownSetting}
>
Create Markdown Setting
</button>
</section>
)
}
</div>
</div>
); );
}; };

View File

@@ -2,6 +2,17 @@ import {useUpdatePath} from "../../utils/queries/path-queries";
import {useCreatePathSetting, usePathSetting} from "../../utils/queries/path-setting-queries"; import {useCreatePathSetting, usePathSetting} from "../../utils/queries/path-setting-queries";
import WebhookSettingPanel from "../Settings/PathSettings/WebhookSettingPanel"; import WebhookSettingPanel from "../Settings/PathSettings/WebhookSettingPanel";
import React, {useState} from "react"; 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 PathSettingModal = ({ isOpen, path, onClose }) => {
const settingId = path?.setting_id || 0; const settingId = path?.setting_id || 0;
const {data: pathSetting, isLoading: isPathSettingLoading} = usePathSetting(settingId); const {data: pathSetting, isLoading: isPathSettingLoading} = usePathSetting(settingId);
@@ -17,59 +28,42 @@ const PathSettingModal = ({ isOpen, path, onClose }) => {
}); });
}; };
if(settingId && isPathSettingLoading)
return (<p>Loading...</p>);
return ( return (
<div className={`modal ${isOpen ? "is-active" : ""}`}> <Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
<div className="modal-background" onClick={onClose} /> <DialogContent className="max-w-3xl">
<div className="modal-card" style={{width: "60vw"}}> <DialogHeader>
<header className="modal-card-head"> <DialogTitle>Path Settings</DialogTitle>
<p className="modal-card-title">Path Settings</p> </DialogHeader>
<button {settingId && isPathSettingLoading ? (
<div className="flex justify-center py-10">
<Spinner label="Loading settings" />
</div>
) : !pathSetting ? (
<Button
type="button" type="button"
className="delete" onClick={handleCreatePathSetting}
aria-label="close" >
onClick={onClose} <Plus className="h-4 w-4" /> Create Path Setting
/> </Button>
</header>
{!pathSetting ? (
<section className="modal-card-body">
<button
type="button"
className="button is-primary"
onClick={handleCreatePathSetting}
>
Create Path Setting
</button>
</section>
) : ( ) : (
<section className="modal-card-body"> <Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="tabs"> <TabsList>
<ul> <TabsTrigger value="webhook">Webhook</TabsTrigger>
<li className={activeTab === "webhook" ? "is-active" : ""}> <TabsTrigger value="template">Template</TabsTrigger>
<a onClick={() => setActiveTab("webhook")}>Webhook</a> </TabsList>
</li> <TabsContent value="webhook">
<li className={activeTab === "template" ? "is-active" : ""}>
<a onClick={() => setActiveTab("template")}>Template</a>
</li>
</ul>
</div>
{activeTab === "webhook" && (
<WebhookSettingPanel <WebhookSettingPanel
pathSetting={pathSetting} pathSetting={pathSetting}
onClose={onClose} onClose={onClose}
/> />
)} </TabsContent>
{activeTab === "template" && ( <TabsContent value="template">
<div></div> <div></div>
)} </TabsContent>
</section> </Tabs>
)} )}
</DialogContent>
</div> </Dialog>
</div>
); );
}; };

View File

@@ -1,46 +1,67 @@
import React, {useContext, useState} 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 {useConfig} from "../../ConfigProvider"; import {
import "./MainNavigation.css"; Download,
Upload,
KeyRound,
KeySquare,
LogOut,
LogIn,
ChevronDown,
Mail,
GitBranch,
} from "lucide-react";
import ApiKeyCreationModal from "../Modals/ApiKeyCreationModal"; import ApiKeyCreationModal from "../Modals/ApiKeyCreationModal";
import ApiKeyRevokeModal from "../Modals/ApiKeyRevokeModal"; 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 config = useConfig();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false); const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false); const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
if (config===undefined) { if (config === undefined) {
return <div>Loading ...</div>; return (
<header className="flex h-14 items-center border-b border-border px-5 text-sm text-muted-foreground">
Loading
</header>
);
} }
const handleLoadBackup = async () => { const handleLoadBackup = async () => {
try{ try {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept=".zip"; input.accept = ".zip";
input.onchange = async (event) => { input.onchange = async (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
if(!file) if (!file) return;
return;
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
try{ try {
const response = await fetch( const response = await fetch(
`${config.BACKEND_HOST}/api/backup/load`, { `${config.BACKEND_HOST}/api/backup/load`,
{
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem("accessToken")}`, Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
}, },
body: formData body: formData,
} }
); );
if(response.ok){ if (response.ok) {
const result = await response.json(); await response.json();
alert("Backup loaded"); alert("Backup loaded");
} else { } else {
const error = await response.json(); const error = await response.json();
@@ -56,25 +77,28 @@ const MainNavigation = () => {
console.error(error); console.error(error);
alert(`Unexpected error`); alert(`Unexpected error`);
} }
} };
const handleGetBackup = async () => { const handleGetBackup = async () => {
try{ try {
const response = await fetch( const response = await fetch(`${config.BACKEND_HOST}/api/backup/`, {
`${config.BACKEND_HOST}/api/backup/`, { method: "GET",
method: "GET", headers: {
headers: { Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
Authorization: `Bearer ${localStorage.getItem("accessToken")}`, },
} });
} if (response.ok) {
);
if(response.ok){
const blob = await response.blob(); const blob = await response.blob();
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
const contentDisposition = response.headers.get("Content-Disposition"); const contentDisposition = response.headers.get(
"Content-Disposition"
);
let filename = "backup.zip"; let filename = "backup.zip";
if (contentDisposition) { if (contentDisposition) {
const match = contentDisposition.match(/filename="?([^"]+)"?/); const match = contentDisposition.match(
/filename="?([^"]+)"?/
);
if (match && match[1]) { if (match && match[1]) {
filename = match[1]; filename = match[1];
} }
@@ -83,10 +107,10 @@ const MainNavigation = () => {
a.download = filename; a.download = filename;
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}else{ } else {
alert("Failed to get backup"); alert("Failed to get backup");
} }
} catch(err){ } catch (err) {
console.log(err); console.log(err);
alert("An error occurred while retrieving backup"); alert("An error occurred while retrieving backup");
} }
@@ -94,114 +118,98 @@ const MainNavigation = () => {
return ( return (
<> <>
<nav className="navbar is-dark" role="navigation" aria-label="main navigation"> <header className="glass relative z-20 flex h-14 shrink-0 items-center gap-1 border-b border-border px-4">
<div className="navbar-brand"> <Link
<Link className="navbar-item" to="/"> to="/"
<img src="/icons/logo.png" alt="Logo" style={{ height: "40px", marginRight: "10px" }} /> className="group flex items-center gap-2.5 pr-3"
Home >
</Link> <img
src="/icons/hangman-lab-logo.svg"
alt="Hangman Lab"
className="h-7 w-7"
/>
<span className="font-display text-base leading-none tracking-tight">
<span className="text-foreground">hangman</span>
<span className="neon-text"> lab</span>
</span>
</Link>
<nav className="ml-2 hidden items-center gap-1 sm:flex">
<a <a
role="button" href="https://mail.hangman-lab.top"
className="navbar-burger" target="_blank"
aria-label="menu" rel="noopener noreferrer"
aria-expanded="false" 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"
data-target="navbarBasicExample"
onClick={(e) => {
e.currentTarget.classList.toggle("is-active");
document
.getElementById("navbarBasicExample")
.classList.toggle("is-active");
}}
> >
<span aria-hidden="true"></span> <Mail className="h-3.5 w-3.5" /> Mail
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a> </a>
</div> <a
href="https://git.hangman-lab.top"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<GitBranch className="h-3.5 w-3.5" /> Git
</a>
</nav>
<div id="navbarBasicExample" className="navbar-menu"> <div className="ml-auto flex items-center">
<div className="navbar-start"> {user && user.profile ? (
<a <DropdownMenu>
href="https://mail.hangman-lab.top" <DropdownMenuTrigger asChild>
className="navbar-item" <Button
target="_blank" variant="outline"
rel="noopener noreferrer" size="sm"
> className="gap-2"
MailBox >
</a> <span className="grid h-5 w-5 place-items-center rounded-full bg-primary/15 font-mono text-[10px] font-bold text-primary">
<a {(user.profile.name || "?")
href="https://git.hangman-lab.top" .charAt(0)
className="navbar-item" .toUpperCase()}
target="_blank" </span>
rel="noopener noreferrer" <span className="max-w-[10rem] truncate">
>
Git
</a>
</div>
<div className="navbar-end">
{user && user.profile ? (
<div className="navbar-item has-dropdown is-hoverable">
<div className="buttons">
<span
className="button is-primary is-light"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
{user.profile.name} {user.profile.name}
</span> </span>
<div className={`navbar-dropdown ${isDropdownOpen ? "is-active" : ""}`}> <ChevronDown className="h-3.5 w-3.5 opacity-60" />
<button </Button>
className="button is-primary dropdown-option" </DropdownMenuTrigger>
onClick={handleGetBackup} <DropdownMenuContent align="end">
type="button" <DropdownMenuLabel>
> {user.profile.name}
Get Backup </DropdownMenuLabel>
</button> <DropdownMenuSeparator />
<button <DropdownMenuItem onClick={handleGetBackup}>
className="button is-primary dropdown-option" <Download /> Get Backup
onClick={handleLoadBackup} </DropdownMenuItem>
type="button" <DropdownMenuItem onClick={handleLoadBackup}>
> <Upload /> Load Backup
Load Backup </DropdownMenuItem>
</button> <DropdownMenuItem
<button onClick={() => setIsApiKeyModalOpen(true)}
className="button is-info dropdown-option"
onClick={() => setIsApiKeyModalOpen(true)}
type="button"
>
Create API Key
</button>
<button
className="button is-warning dropdown-option"
onClick={() => setIsRevokeModalOpen(true)}
type="button"
>
Revoke API Key
</button>
<button
className="button is-danger dropdown-option"
onClick={logout}
type="button"
>
Logout
</button>
</div>
</div>
</div>
) : (
<div className="navbar-item">
<button
className="button is-primary"
onClick={login}
type="button"
> >
Login <KeyRound /> Create API Key
</button> </DropdownMenuItem>
</div> <DropdownMenuItem
)} onClick={() => setIsRevokeModalOpen(true)}
</div> >
<KeySquare /> Revoke API Key
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={logout}
className="text-destructive focus:text-destructive [&_svg]:text-destructive"
>
<LogOut /> Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button size="sm" onClick={login} className="gap-2">
<LogIn className="h-4 w-4" /> Login
</Button>
)}
</div> </div>
</nav> </header>
<ApiKeyCreationModal <ApiKeyCreationModal
isOpen={isApiKeyModalOpen} isOpen={isApiKeyModalOpen}
onClose={() => setIsApiKeyModalOpen(false)} onClose={() => setIsApiKeyModalOpen(false)}
@@ -214,4 +222,4 @@ const MainNavigation = () => {
); );
}; };
export default MainNavigation; export default MainNavigation;

View File

@@ -1,47 +1,82 @@
import {Link} from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import PermissionGuard from "../PermissionGuard"; import PermissionGuard from "../PermissionGuard";
import React, {useState} from "react"; import React, { useState } from "react";
import { FileText, Settings2, Trash2, ChevronUp, ChevronDown } from "lucide-react";
import MarkdownSettingModal from "../Modals/MarkdownSettingModal"; import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
const MarkdownNode = ({markdown, handleMoveMarkdown}) => { 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 [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 ( return (
<li key={markdown.id}> <li>
<div className="is-clickable field has-addons"> <div className="group flex items-center gap-1 rounded-md px-1 py-1 pl-1.5 transition-colors hover:bg-accent/60">
<span className="markdown-name has-text-weight-bold control"> <FileText className="ml-5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Link to={`/markdown/${markdown.id}`} className="is-link markdown-node"> <Link
{markdown.title} to={`/markdown/${markdown.id}`}
</Link> className="min-w-0 flex-1 truncate text-sm text-foreground/90 transition-colors hover:text-primary"
</span> >
{markdown.title}
</Link>
<PermissionGuard rolesRequired={['admin']}> <PermissionGuard rolesRequired={['admin']}>
<p className="control"> <div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<button <button
className="button is-small is-success" type="button"
className={iconBtn}
title="Settings"
onClick={() => setIsMarkdownSettingModalOpen(true)} onClick={() => setIsMarkdownSettingModalOpen(true)}
type="button"
>
<span className="icon">
<i className="fas fa-cog"/>
</span>
</button>
</p>
<div
className="control is-flex is-flex-direction-column is-align-items-center"
style={{marginLeft: "0.5rem"}}
>
<button
className="button is-small mb-1 move-forward"
style={{height: "1rem", padding: "0.25rem"}}
onClick={() => handleMoveMarkdown(markdown, "forward")}
type="button"
> >
<Settings2 className="h-3.5 w-3.5" />
</button> </button>
<button <button
className="button is-small mb-1 move-backward"
style={{height: "1rem", padding: "0.25rem"}}
onClick={() => handleMoveMarkdown(markdown, "backward")}
type="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> </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> </div>
<MarkdownSettingModal <MarkdownSettingModal
isOpen={isMarkdownSettingModalOpen} isOpen={isMarkdownSettingModalOpen}
@@ -50,9 +85,8 @@ const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
/> />
</PermissionGuard> </PermissionGuard>
</div> </div>
</li> </li>
); );
} };
export default MarkdownNode; export default MarkdownNode;

View File

@@ -1,13 +1,27 @@
import React, {useState} from "react"; import React, { useState } from "react";
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { toggleNodeExpansion } from '../../store/navigationSlice'; 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, useMovePath, useUpdatePath} from "../../utils/queries/path-queries"; import { useIndexMarkdown, useMoveMarkdown } from "../../utils/queries/markdown-queries";
import {useIndexMarkdown, useMoveMarkdown} from "../../utils/queries/markdown-queries";
import MarkdownNode from "./MarkdownNode"; import MarkdownNode from "./MarkdownNode";
import PathSettingModal from "../Modals/PathSettingModal"; 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 [isPathSettingModalOpen, setIsPathSettingModalOpen] = useState(false); const [isPathSettingModalOpen, setIsPathSettingModalOpen] = useState(false);
@@ -20,76 +34,57 @@ const PathNode = ({ path, isRoot = false }) => {
const deletePath = useDeletePath(); const deletePath = useDeletePath();
const updatePath = useUpdatePath(); const updatePath = useUpdatePath();
const { data: indexMarkdown } = useIndexMarkdown(path.id);
const {data: indexMarkdown} = useIndexMarkdown(path.id);
const movePath = useMovePath(); const movePath = useMovePath();
const moveMarkdown = useMoveMarkdown(); const moveMarkdown = useMoveMarkdown();
const expand = () => { const expand = () => {
if (!isExpanded) { if (!isExpanded) dispatch(toggleNodeExpansion(path.id));
dispatch(toggleNodeExpansion(path.id));
}
};
const toggleExpand = () => {
dispatch(toggleNodeExpansion(path.id));
}; };
const toggleExpand = () => dispatch(toggleNodeExpansion(path.id));
const handleSave = () => { const handleSave = () => {
updatePath.mutate({id: path.id, data: {name: newName}}, { updatePath.mutate({ id: path.id, data: { name: newName } }, {
onSuccess: () => setIsEditing(false), onSuccess: () => setIsEditing(false),
onError: err => alert("failed to update this path"), onError: () => 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) => { const handleMovePath = (pth, direction) => {
movePath.mutate({path: pth, direction: direction}, { movePath.mutate({ path: pth, direction }, {
onError: () => alert("failed to move this path"), onError: () => alert("failed to move this path"),
}); });
}; };
const handleMoveMarkdown = (md, direction) => { const handleMoveMarkdown = (md, direction) => {
moveMarkdown.mutate({markdown: md, direction: direction}, { moveMarkdown.mutate({ markdown: md, direction }, {
onError: () => alert("failed to move this markdown"), onError: () => alert("failed to move this markdown"),
}) });
}; };
const childPaths = path.children.filter(x => x.type==="path"); const childPaths = path.children.filter(x => x.type === "path");
const sortedPaths = childPaths const sortedPaths = childPaths
? childPaths.slice().sort((a, b) => a.order.localeCompare(b.order)) ? childPaths.slice().sort((a, b) => a.order.localeCompare(b.order))
: []; : [];
const markdowns = path.children.filter(x => x.type === "markdown");
const markdowns = path.children.filter(x => x.type==="markdown");
const sortedMarkdowns = markdowns const sortedMarkdowns = markdowns
? markdowns.filter(md => md.title !== "index").sort((a, b) => a.order.localeCompare(b.order)) ? markdowns.filter(md => md.title !== "index").sort((a, b) => a.order.localeCompare(b.order))
: []; : [];
if (isRoot)
if(isRoot)
return ( return (
<ul className="menu-list"> <ul className="space-y-0.5">
{sortedPaths.map((path) => ( {sortedPaths.map((p) => (
<PathNode <PathNode key={p.id} path={p} isRoot={false} />
key={path.id}
path={path}
isRoot={false}
onSave={handleSave}
onDelete={handleDelete}
/>
))} ))}
{sortedMarkdowns.filter(md => md.title !== "index").map((markdown) => ( {sortedMarkdowns.map((markdown) => (
<MarkdownNode <MarkdownNode
markdown={markdown} markdown={markdown}
handleMoveMarkdown={handleMoveMarkdown} handleMoveMarkdown={handleMoveMarkdown}
@@ -98,106 +93,110 @@ const PathNode = ({ path, isRoot = false }) => {
))} ))}
</ul> </ul>
); );
return (
<li key={path.id}>
<div className="path-node-header field has-addons">
<span className="control has-text-weight-bold path-toggle" onClick={isRoot ? undefined : toggleExpand}>
{isExpanded ? "-" : "+"}
</span>
{isEditing ? (
<div className="control has-icons-left">
<input
className="input is-small path-edit-input"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
return (
<li>
<div className="group flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-accent/60">
<button
type="button"
onClick={toggleExpand}
title={isExpanded ? "Collapse" : "Expand"}
className="flex shrink-0 items-center gap-1 rounded text-muted-foreground transition-colors hover:text-primary"
>
<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>
{isEditing ? (
<input
autoFocus
className="h-6 min-w-0 flex-1 rounded border border-input bg-background/60 px-1.5 text-xs text-foreground focus:border-primary/60 focus:outline-none"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
) : indexMarkdown ? (
// Clicking the name navigates to the folder's index page
// AND expands the subtree (expanded state is global, so
// the children stay visible after navigation).
<Link
to={`/markdown/${indexMarkdown.id}`}
onClick={expand}
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground transition-colors hover:text-primary"
>
{path.name}
</Link>
) : ( ) : (
<span <span
className="path-name has-text-weight-bold control" onClick={toggleExpand}
onClick={isRoot ? undefined : expand} className="min-w-0 flex-1 cursor-pointer truncate text-sm font-medium text-foreground"
> >
{ {path.name}
indexMarkdown ? (
<Link to={`/markdown/${indexMarkdown.id}`} className="is-link index-path-node">
{path.name}
</Link>
) : (
<a className="is-link path-node">{path.name}</a>
)
}
</span> </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">
<p className="control"> <button
<button type="button"
className="button is-small is-success" className={iconBtn}
onClick={() => { title="Settings"
setIsPathSettingModalOpen(true); onClick={() => setIsPathSettingModalOpen(true)}
}}
type="button"
>
<span className="icon">
<i className="fas fa-cog"/>
</span>
</button>
</p>
{isEditing ? (
<p className="control">
<button
className="button is-small is-success"
onClick={handleSave}
type="button"
>
<span className="icon">
<i className="fas fa-check"></i>
</span>
</button>
</p>
) : (
<p className="control">
<button
className="button is-small is-info"
onClick={handleEdit}
type="button"
>
<span className="icon">
<i className="fas fa-pen"></i>
</span>
</button>
</p>
)}
<p className="control">
<button
className="button is-danger is-small"
onClick={handleDelete}
type="button">
<span className="icon">
<i className="fas fa-trash"></i>
</span>
</button>
</p>
<div
className="control is-flex is-flex-direction-column is-align-items-center"
style={{marginLeft: "0.5rem"}}
> >
<Settings2 className="h-3.5 w-3.5" />
</button>
{isEditing ? (
<button <button
className="button is-small mb-1 move-forward"
style={{height: "1rem", padding: "0.25rem"}}
onClick={() => handleMovePath(path, "forward")}
type="button" type="button"
className={iconBtn}
title="Save"
onClick={handleSave}
> >
<Check className="h-3.5 w-3.5 text-primary" />
</button>
) : (
<button
type="button"
className={iconBtn}
title="Rename"
onClick={handleEdit}
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
className={cn(iconBtn, "hover:text-destructive")}
title="Delete"
onClick={handleDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="flex flex-col">
<button
type="button"
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
onClick={() => handleMovePath(path, "forward")}
title="Move up"
>
<ChevronUp className="h-3 w-3" />
</button> </button>
<button <button
className="button is-small mb-1 move-backward"
style={{height: "1rem", padding: "0.25rem"}}
onClick={() => handleMovePath(path, "backward")}
type="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>
</div> </div>
</div> </div>
@@ -210,15 +209,11 @@ const PathNode = ({ path, isRoot = false }) => {
</div> </div>
{isExpanded && ( {isExpanded && (
<ul> <ul className="ml-3 space-y-0.5 border-l border-border/60 pl-2">
{sortedPaths.map((child) => ( {sortedPaths.map((child) => (
<PathNode <PathNode key={child.id} path={child} />
key={child.id}
path={child}
/>
))} ))}
{sortedMarkdowns.map((markdown) => (
{sortedMarkdowns.filter(md => md.title !== "index").map((markdown) => (
<MarkdownNode <MarkdownNode
markdown={markdown} markdown={markdown}
handleMoveMarkdown={handleMoveMarkdown} handleMoveMarkdown={handleMoveMarkdown}

View File

@@ -1,10 +1,12 @@
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { setSelectedTab } from '../../store/navigationSlice'; import { setSelectedTab } from '../../store/navigationSlice';
import "./SideNavigation.css"; import { FolderTree, LayoutTemplate } from "lucide-react";
import TreeTab from "./SideTabs/TreeTab"; import TreeTab from "./SideTabs/TreeTab";
import TemplateTab from "./SideTabs/TemplateTab"; import TemplateTab from "./SideTabs/TemplateTab";
import { AuthContext } from "../../AuthProvider"; import { AuthContext } from "../../AuthProvider";
import { ScrollArea } from "../ui/scroll-area";
import { cn } from "../../lib/utils";
const SideNavigation = () => { const SideNavigation = () => {
const { roles } = useContext(AuthContext); const { roles } = useContext(AuthContext);
@@ -12,8 +14,8 @@ const SideNavigation = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const allTabs = [ const allTabs = [
{ id: "tree", label: "Tree", component: <TreeTab /> }, { id: "tree", label: "Tree", icon: FolderTree, component: <TreeTab /> },
{ id: "templates", label: "Templates", component: <TemplateTab /> }, { id: "templates", label: "Templates", icon: LayoutTemplate, component: <TemplateTab /> },
]; ];
const visibleTabs = roles.includes("admin") const visibleTabs = roles.includes("admin")
@@ -26,28 +28,35 @@ const SideNavigation = () => {
} }
}, [visibleTabs, selectedTab, dispatch]); }, [visibleTabs, selectedTab, dispatch]);
const current = visibleTabs.find(t => t.id === selectedTab); const current = visibleTabs.find(t => t.id === selectedTab);
return ( return (
<aside className="side-nav"> <aside className="flex w-72 shrink-0 flex-col border-r border-border bg-surface/40">
<div className="tabs is-small"> <div className="flex items-center gap-1 border-b border-border p-2">
<ul> {visibleTabs.map(tab => {
{visibleTabs.map(tab => ( const Icon = tab.icon;
<li const active = tab.id === selectedTab;
return (
<button
key={tab.id} key={tab.id}
className={tab.id === selectedTab ? "is-active" : ""} 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"
)}
> >
<a onClick={() => dispatch(setSelectedTab(tab.id))}> <Icon className="h-3.5 w-3.5" />
{tab.label} {tab.label}
</a> </button>
</li> );
))} })}
</ul>
</div>
<div className="tab-content">
{current?.component}
</div> </div>
<ScrollArea className="flex-1">
<div className="p-3">{current?.component}</div>
</ScrollArea>
</aside> </aside>
); );
}; };

View File

@@ -1,8 +1,12 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useMarkdownTemplates } from "../../../utils/queries/markdown-template-queries"; import { useMarkdownTemplates } from "../../../utils/queries/markdown-template-queries";
import PermissionGuard from "../../PermissionGuard"; import PermissionGuard from "../../PermissionGuard";
import { useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Search, LayoutTemplate, Pencil, Braces } from "lucide-react";
import JsonSchemaModal from "../../Modals/JsonSchemaModal"; import JsonSchemaModal from "../../Modals/JsonSchemaModal";
import { Input } from "../../ui/input";
import { Button } from "../../ui/button";
import { Spinner } from "../../ui/misc";
const TemplateTab = () => { const TemplateTab = () => {
const { data: templates, isLoading, error } = useMarkdownTemplates(); const { data: templates, isLoading, error } = useMarkdownTemplates();
@@ -111,53 +115,59 @@ const TemplateTab = () => {
return schema; return schema;
}; };
if (isLoading) return <p>Loading...</p>; if (isLoading) return <Spinner label="Loading templates" />;
if (error) return <p>Error loading templates</p>; if (error)
return (
<p className="font-mono text-xs text-destructive">
Error loading templates
</p>
);
return ( return (
<aside className="menu"> <div className="space-y-3">
<div className="control is-expanded"> <div className="relative">
<input <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" />
className="input is-small" <Input
className="h-8 pl-8 text-xs"
type="text" type="text"
placeholder="Search templates..." placeholder="Search templates"
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
/> />
</div> </div>
<PermissionGuard rolesRequired={["admin", "creator"]}> <PermissionGuard rolesRequired={["admin", "creator"]}>
<a <Button asChild size="sm" variant="outline" className="w-full">
href="/template/create" <Link to="/template/create">
className="button is-primary is-small is-fullwidth" <LayoutTemplate className="h-4 w-4" /> New Template
style={{ marginBottom: "10px" }} </Link>
> </Button>
Create New Template
</a>
</PermissionGuard> </PermissionGuard>
<ul className="menu-list"> <ul className="space-y-0.5">
{filteredTemplates?.map((template) => ( {filteredTemplates?.map((template) => (
<li key={template.id}> <li
<div className="is-flex is-justify-content-space-between is-align-items-center"> key={template.id}
<span>{template.title}</span> className="group flex items-center gap-1 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/60"
<div className="field has-addons is-justify-content-flex-end"> >
<button <LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-secondary/80" />
className="button is-small control" <span className="min-w-0 flex-1 truncate text-sm text-foreground/90">
onClick={() => handleTemplateClick(template.id)} {template.title}
type="button" </span>
> <div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<span className="icon"> <button
<i className="fas fa-edit"></i> type="button"
</span> className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
</button> title="Edit"
<button onClick={() => handleTemplateClick(template.id)}
className="button is-small control" >
onClick={() => setSelectedSchema(generateJsonSchema(template))} <Pencil className="h-3.5 w-3.5" />
type="button" </button>
> <button
<span className="icon"> type="button"
<i className="fas fa-code"></i> className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
</span> title="JSON schema"
</button> onClick={() => setSelectedSchema(generateJsonSchema(template))}
</div> >
<Braces className="h-3.5 w-3.5" />
</button>
</div> </div>
</li> </li>
))} ))}
@@ -167,7 +177,7 @@ const TemplateTab = () => {
onClose={() => setSelectedSchema(null)} onClose={() => setSelectedSchema(null)}
schema={selectedSchema} schema={selectedSchema}
/> />
</aside> </div>
); );
}; };

View File

@@ -1,42 +1,40 @@
import PermissionGuard from "../../PermissionGuard"; import PermissionGuard from "../../PermissionGuard";
import PathNode from "../PathNode"; import PathNode from "../PathNode";
import React from "react"; import React from "react";
import {useTree} from "../../../utils/queries/tree-queries"; import { Link } from "react-router-dom";
import {useDeletePath, useUpdatePath} from "../../../utils/queries/path-queries"; 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 TreeTab = () => {
const {data: tree, isLoading, error} = useTree(); const { data: tree, isLoading, error } = useTree();
const deletePath = useDeletePath(); const deletePath = useDeletePath();
const updatePath = useUpdatePath(); const updatePath = useUpdatePath();
const [keyword, setKeyword] = React.useState(""); const [keyword, setKeyword] = React.useState("");
const handleDelete = (id) => { const handleDelete = (id) => {
if (window.confirm("Are you sure you want to delete this path?")){ if (window.confirm("Are you sure you want to delete this path?")) {
deletePath.mutate(id, { deletePath.mutate(id, {
onError: (err) => { onError: () => alert("Failed to delete path"),
alert("Failed to delete path");
},
}); });
} }
}; };
const filterTree = (t, k) => { const filterTree = (t, k) => {
if(t === undefined) if (t === undefined) return undefined;
return undefined;
if (t.type === "path") { if (t.type === "path") {
if (t.name.includes(k)) { if (t.name.includes(k)) return { ...t };
return { ...t };
}
const filteredChildren = (t.children || []) const filteredChildren = (t.children || [])
.map(c => filterTree(c, k)) .map(c => filterTree(c, k))
.filter(Boolean); .filter(Boolean);
if (filteredChildren.length > 0) { if (filteredChildren.length > 0) {
return { ...t, children: filteredChildren }; return { ...t, children: filteredChildren };
} }
} else if (t.type === "markdown") { } else if (t.type === "markdown") {
if (t.title.includes(k)) { if (t.title.includes(k)) return { ...t };
return { ...t };
}
} }
return undefined; return undefined;
}; };
@@ -44,35 +42,49 @@ const TreeTab = () => {
const filteredTree = filterTree(tree, keyword); const filteredTree = filterTree(tree, keyword);
const handleSave = (id, newName) => { const handleSave = (id, newName) => {
updatePath.mutate({ id, data: {name: newName }} , { updatePath.mutate({ id, data: { name: newName } }, {
onError: (err) => { onError: () => alert("Failed to update path"),
alert("Failed to update path");
}
}); });
}; };
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading tree</p>;
return (
<aside className="menu">
<div className="control is-expanded"> if (isLoading) return <Spinner label="Loading tree" />;
<input if (error)
className="input is-small" 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" type="text"
placeholder="Search..." placeholder="Search"
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
/> />
</div> </div>
<PermissionGuard rolesRequired={["admin", "creator"]}> <PermissionGuard rolesRequired={["admin", "creator"]}>
<a <Button
href="/markdown/create" asChild
className="button is-primary is-small" size="sm"
variant="outline"
className="w-full"
> >
Create New Markdown <Link to="/markdown/create">
</a> <FilePlus2 className="h-4 w-4" /> New Markdown
</Link>
</Button>
</PermissionGuard> </PermissionGuard>
{!filteredTree || filteredTree.length === 0 ?
<p>No Result</p> : {!filteredTree || filteredTree.length === 0 ? (
<p className="px-1 py-6 text-center font-mono text-xs text-muted-foreground">
No result
</p>
) : (
<PathNode <PathNode
key={1} key={1}
path={filteredTree} path={filteredTree}
@@ -80,9 +92,9 @@ const TreeTab = () => {
onSave={handleSave} onSave={handleSave}
onDelete={handleDelete} onDelete={handleDelete}
/> />
} )}
</aside> </div>
); );
}; };
export default TreeTab; 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,9 +1,13 @@
import React, {useEffect, useState, useRef, useContext} from "react"; import React, {useEffect, useState, useRef, useContext} from "react";
import {useCreatePath, usePaths} from "../utils/queries/path-queries"; import {useCreatePath, usePaths} from "../utils/queries/path-queries";
import { useQueryClient } from "@tanstack/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 }) => {
@@ -104,30 +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 field has-addons"> <div className="flex flex-wrap items-center gap-1.5">
<div className="current-path control"> {currentFullPath.map((path, index) => (
{currentFullPath.map((path, index) => ( <button
<span type="button"
key={path.id} key={path.id}
className="tag is-clickable is-link is-light" 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 + "/"}
</span> </button>
))} ))}
</div>
<div className="control">
<span>&nbsp;&nbsp;&nbsp;&nbsp;</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}
@@ -136,42 +136,42 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
onBlur={handleInputBlur} onBlur={handleInputBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
</div> {dropdownActive && (
<div className="control"> <div className="path-manager-dropdown absolute left-0 top-full z-20 mt-1 w-full overflow-y-auto rounded-md border border-border bg-card shadow-glow">
<button
className="button is-small is-primary"
onClick={handleAddDirectory}
disabled={isSubPathsLoading || !searchTerm.trim()}
type="button"
>
Create "{searchTerm}"
</button>
</div>
</div>
{dropdownActive && (
<div className="dropdown is-active">
<div className="dropdown-menu">
<div className="dropdown-content">
{filteredSubPaths.length > 0 ? ( {filteredSubPaths.length > 0 ? (
filteredSubPaths.map((subPath) => ( filteredSubPaths.map((subPath) => (
<a <button
type="button"
key={subPath.id} key={subPath.id}
className="dropdown-item" className="block w-full px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-accent"
onClick={() => handleSubPathSelect(subPath)} onClick={() => handleSubPathSelect(subPath)}
> >
{subPath.name} {subPath.name}
</a> </button>
)) ))
) : ( ) : (
<div className="dropdown-item">No matches found</div> <div className="px-3 py-2 text-xs text-muted-foreground">
No matches found
</div>
)} )}
</div> </div>
</div> )}
</div> </div>
<Button
type="button"
size="sm"
onClick={handleAddDirectory}
disabled={isSubPathsLoading || !searchTerm.trim()}
>
<Plus className="h-4 w-4" /> Create "{searchTerm}"
</Button>
</div>
{isSubPathsLoading && <Spinner label="Loading paths" />}
{subPathsError && (
<p className="font-mono text-xs text-destructive">
Error loading subdirectories.
</p>
)} )}
{isSubPathsLoading && <p>Loading...</p>}
{subPathsError && <p>Error loading subdirectories.</p>}
</div> </div>
</div> </div>
); );

View File

@@ -4,7 +4,14 @@ import {
useUpdateMarkdownPermissionSetting useUpdateMarkdownPermissionSetting
} from "../../../utils/queries/markdown-permission-setting-queries"; } from "../../../utils/queries/markdown-permission-setting-queries";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import { Plus, Save } from "lucide-react";
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries"; 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 MarkdownPermissionSettingPanel = ({markdownSetting, onClose}) => {
const {data: setting, isFetching: settingIsFetching } = useMarkdownPermissionSetting(markdownSetting?.permission_setting_id); const {data: setting, isFetching: settingIsFetching } = useMarkdownPermissionSetting(markdownSetting?.permission_setting_id);
@@ -49,42 +56,46 @@ const MarkdownPermissionSettingPanel = ({markdownSetting, onClose}) => {
}; };
if (settingIsFetching) { if (settingIsFetching) {
return (<p>Loading...</p>); return (
<div className="flex justify-center py-6">
<Spinner label="Loading permission" />
</div>
);
} }
return setting ? ( return setting ? (
<div className="box" style={{marginTop: "1rem"}}> <div className="mt-4 space-y-4 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="title is-5">Permission Setting</h4> <h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
<div className="field"> Permission Setting
<label className="label">Permission</label> </h4>
<div className="select is-fullwidth"> <div className="space-y-2">
<select <Label htmlFor="permission-select">Permission</Label>
value={permission} <select
onChange={(e) => setPermission(e.target.value)} id="permission-select"
> className={SELECT_CLASS}
<option value="">(None)</option> value={permission}
<option value="public">public</option> onChange={(e) => setPermission(e.target.value)}
<option value="protected">protected</option> >
<option value="private">private</option> <option value="">(None)</option>
</select> <option value="public">public</option>
</div> <option value="protected">protected</option>
<option value="private">private</option>
</select>
</div> </div>
<button <Button
className="button is-primary"
type="button" type="button"
onClick={handleSaveMarkdownPermissionSetting} onClick={handleSaveMarkdownPermissionSetting}
> >
Save Permission Setting <Save className="h-4 w-4" /> Save Permission Setting
</button> </Button>
</div> </div>
) : ( ) : (
<button <Button
className="button is-primary"
type="button" type="button"
onClick={handleCreatePermissionSetting} onClick={handleCreatePermissionSetting}
> >
Create Permission Setting <Plus className="h-4 w-4" /> Create Permission Setting
</button> </Button>
); );
}; };

View File

@@ -8,7 +8,14 @@ import {
useUpdateMarkdownTemplateSetting useUpdateMarkdownTemplateSetting
} from "../../../utils/queries/markdown-template-setting-queries"; } from "../../../utils/queries/markdown-template-setting-queries";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import { Plus, Save } from "lucide-react";
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries"; 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 MarkdownTemplateSettingPanel = ({markdownSetting, onClose}) => {
const {data: setting, isFetching: settingIsFetching } = useMarkdownTemplateSetting(markdownSetting?.template_setting_id); const {data: setting, isFetching: settingIsFetching } = useMarkdownTemplateSetting(markdownSetting?.template_setting_id);
@@ -51,44 +58,48 @@ const MarkdownTemplateSettingPanel = ({markdownSetting, onClose}) => {
} }
},[template, selectedTemplateId]); },[template, selectedTemplateId]);
if (settingIsFetching || templatesAreFetching || templatesAreFetching || templateIsFetching) { if (settingIsFetching || templatesAreFetching || templatesAreFetching || templateIsFetching) {
return (<p>Loading...</p>); return (
<div className="flex justify-center py-6">
<Spinner label="Loading template" />
</div>
);
} }
return setting ? ( return setting ? (
<div className="box" style={{marginTop: "1rem"}}> <div className="mt-4 space-y-4 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="title is-5">Template Setting</h4> <h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
<div className="field"> Template Setting
<label className="label">Use Template</label> </h4>
<div className="select is-fullwidth"> <div className="space-y-2">
<select <Label htmlFor="use-template-select">Use Template</Label>
value={selectedTemplateId} <select
onChange={(e) => { id="use-template-select"
setSelectedTemplateId(e.target.value); className={SELECT_CLASS}
}} value={selectedTemplateId}
> onChange={(e) => {
<option value="">(default)</option> setSelectedTemplateId(e.target.value);
{templates.map((_template, index) => ( }}
<option key={index} value={_template.id}>{_template.title}</option> >
))} <option value="">(default)</option>
{templates.map((_template, index) => (
<option key={index} value={_template.id}>{_template.title}</option>
))}
</select> </select>
</div>
</div> </div>
<button <Button
className="button is-primary"
type="button" type="button"
onClick={handleSaveMarkdownTemplateSetting} onClick={handleSaveMarkdownTemplateSetting}
> >
Save Template Setting <Save className="h-4 w-4" /> Save Template Setting
</button> </Button>
</div> </div>
) : ( ) : (
<button <Button
className="button is-primary"
type="button" type="button"
onClick={handleCreateTemplateSetting} onClick={handleCreateTemplateSetting}
> >
Create Template Setting <Plus className="h-4 w-4" /> Create Template Setting
</button> </Button>
); );
}; };

View File

@@ -12,6 +12,25 @@ import {
useDeleteWebhook, useDeleteWebhook,
} from "../../../utils/queries/webhook-queries"; } from "../../../utils/queries/webhook-queries";
import {useUpdatePathSetting} from "../../../utils/queries/path-setting-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 WebhookSettingPanel = ({pathSetting, onClose}) => {
@@ -213,219 +232,179 @@ const WebhookSettingPanel = ({pathSetting, onClose}) => {
}; };
return setting ? ( return setting ? (
<div className="box" style={{ marginTop: "1rem" }}> <div className="mt-4 space-y-5 rounded-lg border border-border bg-surface/40 p-5">
<h4 className="title is-5">Webhook Setting</h4> <h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
<div className="field"> Webhook Setting
<label className="label">Select or Create a Webhook</label> </h4>
<div className="field has-addons"> <div className="space-y-2">
<div className="control is-expanded"> <Label>Select or Create a Webhook</Label>
<div className="flex items-center gap-2">
<div className="flex-1">
{isWebhooksLoading ? ( {isWebhooksLoading ? (
<p>Loading...</p> <Spinner label="Loading webhooks" />
) : ( ) : (
<div className="select is-fullwidth"> <select
<select className={SELECT_CLASS}
value={selectedUrl} value={selectedUrl}
onChange={(e) => setSelectedUrl(e.target.value)} onChange={(e) => setSelectedUrl(e.target.value)}
> >
<option value="">(none)</option> <option value="">(none)</option>
{webhooks.map((hook) => ( {webhooks.map((hook) => (
<option key={hook.id} value={hook.hook_url}> <option key={hook.id} value={hook.hook_url}>
{hook.hook_url} {hook.hook_url}
</option> </option>
))} ))}
</select> </select>
</div>
)} )}
</div> </div>
<div className="control"> <Button
<button type="button"
type="button" onClick={handleCreateWebhook}
className="button is-primary" >
onClick={handleCreateWebhook} <Plus className="h-4 w-4" /> Add
> </Button>
Add
</button>
</div>
</div> </div>
{setting?.webhook_id && ( {setting?.webhook_id && (
<div className="buttons" style={{ marginTop: "0.5rem" }}> <div className="flex flex-wrap gap-2 pt-1">
<button <Button
type="button" type="button"
className="button is-info" variant="outline"
size="sm"
onClick={handleUpdateWebhook} onClick={handleUpdateWebhook}
> >
Update Webhook URL <Pencil className="h-4 w-4" /> Update Webhook URL
</button> </Button>
<button <Button
type="button" type="button"
className="button is-danger" variant="destructive"
size="sm"
onClick={handleDeleteWebhook} onClick={handleDeleteWebhook}
> >
Delete Webhook <Trash2 className="h-4 w-4" /> Delete Webhook
</button> </Button>
</div> </div>
)} )}
</div> </div>
<div className="field"> <CheckboxRow
<label className="checkbox"> checked={enabled}
<input onChange={(e) => setEnabled(e.target.checked)}
type="checkbox" label="Enabled"
checked={enabled} />
onChange={(e) => setEnabled(e.target.checked)}
/>
&nbsp; Enabled
</label>
</div>
<div className="field"> <div className="space-y-2">
<label className="label">On Events</label> <Label>On Events</Label>
<div className="box"> <div className="grid grid-cols-1 gap-3 rounded-md border border-border bg-background/40 p-4 sm:grid-cols-2">
<div className="columns"> <div className="space-y-2">
<div className="column"> <CheckboxRow
<label className="checkbox"> checked={isOnMarkdownCreated}
<input onChange={(e) =>
type="checkbox" handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked)
checked={isOnMarkdownCreated} }
onChange={(e) => label="Markdown Created"
handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked) />
} <CheckboxRow
/> checked={isOnMarkdownUpdated}
&nbsp; Markdown Created onChange={(e) =>
</label> handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked)
<br /> }
<label className="checkbox"> label="Markdown Updated"
<input />
type="checkbox" <CheckboxRow
checked={isOnMarkdownUpdated} checked={isOnMarkdownDeleted}
onChange={(e) => onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked) handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked)
} }
/> label="Markdown Deleted"
&nbsp; Markdown Updated />
</label> </div>
<br /> <div className="space-y-2">
<label className="checkbox"> <CheckboxRow
<input checked={isOnPathCreated}
type="checkbox" onChange={(e) =>
checked={isOnMarkdownDeleted} handleTriggerEventsUpdate("PATH_CREATED", e.target.checked)
onChange={(e) => }
handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked) label="Path Created"
} />
/> <CheckboxRow
&nbsp; Markdown Deleted checked={isOnPathUpdated}
</label> onChange={(e) =>
</div> handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
<div className="column"> }
<label className="checkbox"> label="Path Updated"
<input />
type="checkbox" <CheckboxRow
checked={isOnPathCreated} checked={isOnPathDeleted}
onChange={(e) => onChange={(e) =>
handleTriggerEventsUpdate("PATH_CREATED", e.target.checked) handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
} }
/> label="Path Deleted"
&nbsp; Path Created />
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
}
/>
&nbsp; Path Updated
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
}
/>
&nbsp; Path Deleted
</label>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="field"> <CheckboxRow
<label className="checkbox"> checked={isRecursive}
<input onChange={(e) => setIsRecursive(e.target.checked)}
type="checkbox" label="Recursive"
checked={isRecursive} />
onChange={(e) => setIsRecursive(e.target.checked)}
/>
&nbsp; Recursive
</label>
</div>
<div className="field"> <div className="space-y-2">
<label className="label">Additional Headers</label> <Label>Additional Headers</Label>
<div className="box"> <div className="space-y-3 rounded-md border border-border bg-background/40 p-4">
{headerList.map((h, idx) => ( {headerList.map((h, idx) => (
<div className="columns" key={idx}> <div className="flex gap-2" key={idx}>
<div className="column"> <Input
<input type="text"
type="text" placeholder="key"
className="input" value={h.key}
placeholder="key" onChange={(e) => handleHeaderChange(idx, "key", e.target.value)}
value={h.key} />
onChange={(e) => handleHeaderChange(idx, "key", e.target.value)} <Input
/> type="text"
</div> placeholder="value"
<div className="column"> value={h.value}
<input onChange={(e) =>
type="text" handleHeaderChange(idx, "value", e.target.value)
className="input" }
placeholder="value" />
value={h.value}
onChange={(e) =>
handleHeaderChange(idx, "value", e.target.value)
}
/>
</div>
</div> </div>
))} ))}
<button <div className="flex gap-2">
type="button" <Button
className="button is-small is-info" type="button"
onClick={handleAddHeader} variant="outline"
> size="sm"
+ Header onClick={handleAddHeader}
</button> >
<button <Plus className="h-4 w-4" /> Header
type="button" </Button>
className="button is-small is-success" <Button
onClick={handleApplyHeaders} type="button"
style={{ marginLeft: "0.5rem" }} size="sm"
> onClick={handleApplyHeaders}
Apply >
</button> <Check className="h-4 w-4" /> Apply
</Button>
</div>
</div> </div>
</div> </div>
<button <Button
type="button" type="button"
className="button is-primary"
onClick={handleSaveWebhookSetting} onClick={handleSaveWebhookSetting}
> >
Save Webhook Setting <Save className="h-4 w-4" /> Save Webhook Setting
</button> </Button>
</div> </div>
) : ( ) : (
<button <Button
className="button is-primary"
type="button" type="button"
onClick={handleCreateWebhookSetting} onClick={handleCreateWebhookSetting}
> >
Create Webhook Setting <Plus className="h-4 w-4" /> Create Webhook Setting
</button> </Button>
); );
} }

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

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",
});
}

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

@@ -52,6 +52,7 @@ export const useMarkdownsByPath = (pathId) => {
}); });
}; };
export const useSaveMarkdown = () => { export const useSaveMarkdown = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const config = useConfig(); const config = useConfig();
@@ -93,6 +94,24 @@ export const useMoveMarkdown = () => {
}); });
}; };
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) => { export const useSearchMarkdown = (keyword) => {
const config = useConfig(); const config = useConfig();
return useQuery({ return useQuery({

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] });
},
});
};

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

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

69
tailwind.config.js Normal file
View File

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

View File

@@ -5,7 +5,11 @@ module.exports = {
entry: './src/index.js', entry: './src/index.js',
output: { output: {
path: path.resolve(__dirname, './dist'), path: path.resolve(__dirname, './dist'),
filename: 'bundle.js', // Content-hashed filename: index.html (injected by HtmlWebpackPlugin,
// and not edge-cached) points at a unique URL each build, so a new
// deploy is picked up immediately — no stale CDN/browser bundle.
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js',
publicPath: '/', publicPath: '/',
clean: true, clean: true,
}, },
@@ -20,7 +24,7 @@ module.exports = {
}, },
{ {
test: /\.css$/, test: /\.css$/,
use: ['style-loader', 'css-loader'], use: ['style-loader', 'css-loader', 'postcss-loader'],
} }
] ]
}, },