Compare commits

..

4 Commits

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

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

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

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

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

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

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: {},
},
};

View File

@@ -1,11 +1,17 @@
<!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/png" href="./icons/logo.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

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

@@ -13,29 +13,29 @@ 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}>
<TooltipProvider delayDuration={200}>
<Router> <Router>
<Routes> <Routes>
<Route path="/pg/*" element={<StandaloneMarkdownPage />} /> <Route path="/pg/*" element={<StandaloneMarkdownPage />} />
<Route path="*" element={ <Route path="*" element={
<div className="app-container"> <div className="relative z-10 flex h-screen flex-col">
<MainNavigation /> <MainNavigation />
<div className="content-container"> <div className="flex flex-1 overflow-hidden">
<SideNavigation /> <SideNavigation />
<main className="main-content"> <main className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-5xl px-6 py-8 pb-28">
<Routes> <Routes>
<Route <Route
path="/" path="/"
element={<Navigate to = "/markdown/1"/>} element={<Navigate to="/markdown/1" />}
/> />
<Route path="/testx" element={<h2>test2</h2>}/>
<Route path="/markdown/:strId" element={<MarkdownContent />} /> <Route path="/markdown/:strId" element={<MarkdownContent />} />
<Route path="/callback" element={<Callback />} /> <Route path="/callback" element={<Callback />} />
<Route path="/test" element={<h1>TEST</h1>}></Route>
<Route path="/markdown/create" element={<MarkdownEditor />} /> <Route path="/markdown/create" element={<MarkdownEditor />} />
<Route path="/markdown/edit/:strId" element={<MarkdownEditor />} /> <Route path="/markdown/edit/:strId" element={<MarkdownEditor />} />
<Route path="/popup_callback" element={<PopupCallback />} /> <Route path="/popup_callback" element={<PopupCallback />} />
@@ -43,13 +43,15 @@ const App = () => {
<Route path="/template/create" element={<MarkdownTemplateEditor />} /> <Route path="/template/create" element={<MarkdownTemplateEditor />} />
<Route path="/template/edit/:strId" element={<MarkdownTemplateEditor />} /> <Route path="/template/edit/:strId" element={<MarkdownTemplateEditor />} />
</Routes> </Routes>
</div>
</main> </main>
</div> </div>
<Footer />
</div> </div>
} /> } />
</Routes> </Routes>
<Footer />
</Router> </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
className="toggle-button"
onClick={toggleExpand}
type="button"
>
{isExpanded ? "↓" : "↑"}
</button>
<div className={`footer-content`}>
<p>&copy; {new Date().getFullYear()} Hangman Lab. {!isVisible && (<span>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="mailto:hzhang@hangman-lab.top">email</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://git.hangman-lab.top/hzhang/HangmanLab">git</a>
&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;
</span>
)}</p>
{
isVisible && (
<div <div
className={`footer-details ${isExpanded ? "expanded" : ""}`} className={cn(
onTransitionEnd={onTransitionEnd} "overflow-hidden transition-all duration-300",
open ? "max-h-20" : "max-h-0"
)}
> >
<div className="footer-icons"> <div className="flex items-center justify-center gap-6 py-3 text-xs">
<a <a
href="https://www.linkedin.com/in/zhhrozhh/" href="https://www.linkedin.com/in/zhhrozhh/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
> >
<img <Linkedin className="h-3.5 w-3.5" /> LinkedIn
src="/icons/linkedin.png"
alt="LinkedIn"
className="footer-icon"
/>
LinkedIn
</a> </a>
<a <a
href="https://git.hangman-lab.top/hzhang/HangmanLab" href="https://git.hangman-lab.top/hzhang/HangmanLab"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
> >
<img <GitBranch className="h-3.5 w-3.5" /> Git
src="/icons/git.png"
alt="Git"
className="footer-icon"
/>
Git
</a> </a>
<a href="mailto:hzhang@hangman-lab.top"> <a
<img href="mailto:hzhang@hangman-lab.top"
src="/icons/email.png" className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
alt="Email" >
className="footer-icon" <Mail className="h-3.5 w-3.5" /> Email
/>
Email
</a> </a>
</div> </div>
</div> </div>
) <div className="flex h-9 items-center justify-between px-4">
} <p className="font-mono text-[11px] text-muted-foreground">
© {new Date().getFullYear()}{" "}
<span className="text-foreground/70">Hangman Lab</span>
</p>
<button
type="button"
onClick={() => setOpen((p) => !p)}
className="inline-flex items-center gap-1 rounded px-2 py-0.5 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronUp
className={cn(
"h-3.5 w-3.5 transition-transform",
open && "rotate-180"
)}
/>
{open ? "less" : "more"}
</button>
</div> </div>
</footer> </footer>
); );

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,18 @@
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) => {
@@ -20,11 +22,10 @@ const EnumsEditor = ({ enums, onChange }) => {
onChange(updated); onChange(updated);
}} }}
/> />
</div> <Button
<div className="control">
<button
className="button is-small is-danger"
type="button" type="button"
variant="destructive"
size="icon-sm"
onClick={() => { onClick={() => {
const updated = [..._enums]; const updated = [..._enums];
updated.splice(index, 1); updated.splice(index, 1);
@@ -32,32 +33,23 @@ const EnumsEditor = ({ enums, onChange }) => {
onChange(updated); onChange(updated);
}} }}
> >
<span className="icon is-small"> <X className="h-3.5 w-3.5" />
<i className="fas fa-times" /> </Button>
</span>
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
<div className="field"> <Button
<div className="control">
<button
className="button is-small is-primary"
type="button" type="button"
variant="outline"
size="sm"
onClick={() => { onClick={() => {
const updated = [..._enums, ""]; const updated = [..._enums, ""];
setEnums(updated); setEnums(updated);
onChange(updated); onChange(updated);
}} }}
> >
<span className="icon is-small"> <Plus className="h-4 w-4" /> Add Enum
<i className="fas fa-plus" /> </Button>
</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,50 +58,47 @@ 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} parameters={parameters}
onChange={(newLayout) => setLayout(newLayout)} onChange={(newLayout) => setLayout(newLayout)}
/> />
</div> </div>
</div> </div>
<div className="column"> <div className="space-y-3">
<h3 className="title is-5">Parameters</h3> <h3 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
Parameters
</h3>
<ParametersManager <ParametersManager
parameters={parameters} parameters={parameters}
onChange={(newParameters) => setParameters(newParameters)} onChange={(newParameters) => setParameters(newParameters)}
/> />
</div>
</div>
<div className="field is-grouped">
<div className="control">
<button className="button is-primary" onClick={handleSave}>
Save Template
</button>
</div> </div>
</div> </div>
<div className="mt-6">
<Button onClick={handleSave}>
<Save className="h-4 w-4" /> Save Template
</Button>
</div> </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 || []);
@@ -59,56 +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-align-items-end"> <div className="flex items-end gap-2">
<div className="control"> <div className="flex-1 space-y-1.5">
<label className="label">Name:</label> <Label>Name</Label>
</div> <Input
<div className="control is-expanded">
<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"
size="icon"
onClick={() => handleDelete(index)} onClick={() => handleDelete(index)}
> >
Delete <Trash2 className="h-4 w-4" />
</button> </Button>
</div> </div>
</div> <div className="space-y-2">
<div className="field"> <div className="flex items-center justify-between">
<div className="is-flex is-justify-content-space-between is-align-items-center mb-1"> <Label>Type</Label>
<label className="label mb-0">Type:</label> <Button
<button type="button"
className="button is-small" 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,15 +27,15 @@ 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">
<div className="select is-fullwidth is-primary">
<select <select
id="template-selector"
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);
@@ -53,8 +58,6 @@ const TemplateSelector = ({ template, onChange, onCreate }) => {
))} ))}
</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,9 +18,8 @@ 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) => {
@@ -27,15 +30,13 @@ const TypeEditor = ({ type, onChange }) => {
}} }}
/> />
</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) => {
@@ -43,13 +44,11 @@ const TypeEditor = ({ type, onChange }) => {
}} }}
/> />
</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({ updateType({
@@ -63,13 +62,10 @@ const TypeEditor = ({ type, onChange }) => {
/> />
</div> </div>
</div> </div>
</div>
); );
case 'template': case 'template':
return ( return (
<div className="field">
<div className="control">
<TemplateSelector <TemplateSelector
template={_type.definition.template} template={_type.definition.template}
onChange={(newTemplate) => { onChange={(newTemplate) => {
@@ -82,8 +78,6 @@ const TypeEditor = ({ type, onChange }) => {
}); });
}} }}
/> />
</div>
</div>
); );
default: default:
@@ -92,12 +86,9 @@ const TypeEditor = ({ type, onChange }) => {
}; };
return ( return (
<div className="box"> <div className="space-y-4 rounded-md border border-border bg-surface/40 p-4">
<div className="field">
{/*<label className="label">Type</label>*/}
<div className="control">
<div className="select is-fullwidth">
<select <select
className={SELECT_CLASS}
value={_type.base_type || ''} value={_type.base_type || ''}
onChange={(e) => { onChange={(e) => {
const updated = { base_type: e.target.value, definition: {} }; const updated = { base_type: e.target.value, definition: {} };
@@ -110,9 +101,6 @@ const TypeEditor = ({ type, onChange }) => {
<option value="list">list</option> <option value="list">list</option>
<option value="template">template</option> <option value="template">template</option>
</select> </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,7 +2,9 @@ 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 } from "lucide-react";
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";
@@ -10,6 +12,9 @@ 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 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();
@@ -32,49 +37,62 @@ 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="is-flex is-justify-content-space-between is-align-items-center markdown-content-container-header"> <div className="mb-6 flex items-start justify-between gap-4 border-b border-border pb-4">
<h1 className="title">{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']}>
<div className="field has-addons"> <div className="flex shrink-0 items-center gap-2">
<button <Button
className="control button is-info is-light" variant="outline"
size="sm"
onClick={() => setSettingModalOpen(true)} onClick={() => setSettingModalOpen(true)}
> >
Settings <Settings2 className="h-4 w-4" /> Settings
</button> </Button>
<Link to={`/markdown/edit/${id}`} className="control button is-primary is-light"> <Button asChild size="sm">
Edit <Link to={`/markdown/edit/${id}`}>
<Pencil className="h-4 w-4" /> Edit
</Link> </Link>
</Button>
</div> </div>
</PermissionGuard> </PermissionGuard>
</div> </div>
<MarkdownView content={JSON.parse(markdown.content)} template={template}/> <MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/>
<PatchCards markdownId={id} />
<MarkdownSettingModal <MarkdownSettingModal
isOpen={isSettingModalOpen} isOpen={isSettingModalOpen}
markdown={markdown} markdown={markdown}
onClose={() => setSettingModalOpen(false)} onClose={() => setSettingModalOpen(false)}
/> />
</div> </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, "JetBrains 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,85 +202,96 @@ 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 ( return (
<div className="container mt-5 markdown-editor-container"> <div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
<h2 className="title is-4">{id ? "Edit Markdown" : "Create Markdown"}</h2> {error.message || "Failed to load markdown"}
<div className="columns"> </div>
<div className="column is-half"> );
<form> return (
<div className="field"> <div className="markdown-editor-container mx-auto max-w-[90vw] px-6 py-8">
<label className="label">Title</label> <h2 className="mb-6 font-mono text-2xl font-bold tracking-tight text-foreground">
<div className="control"> {id ? "Edit Markdown" : "Create Markdown"}
<input </h2>
className="input" <div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div>
<form className="space-y-5">
<div className="space-y-2">
<Label htmlFor="md-title">Title</Label>
<Input
id="md-title"
type="text" type="text"
placeholder="Enter title" placeholder="Enter title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
</div> </div>
</div>
<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="space-y-2">
<div className="field"> <Label>Path</Label>
<label className="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'}
</button>
</div>
<div className="control">
{isRawMode ? ( {isRawMode ? (
<div> <>
<p className="help mb-2"> <LayoutTemplate className="h-4 w-4" /> Switch to Template Editor
</>
) : (
<>
<Code className="h-4 w-4" /> Switch to Raw Editor
</>
)}
</Button>
</div>
{isRawMode ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Edit the JSON directly. Make sure it's valid JSON before saving. Edit the JSON directly. Make sure it's valid JSON before saving.
</p> </p>
<textarea <Textarea
className={`textarea raw-editor ${jsonError ? 'is-danger' : ''}`} className={`raw-editor h-[70vh] font-mono text-sm ${jsonError ? "border-destructive focus-visible:border-destructive" : ""}`}
style={{height: "70vh"}}
value={rawContent} value={rawContent}
onChange={handleRawContentChange} onChange={handleRawContentChange}
placeholder="Enter JSON content here" placeholder="Enter JSON content here"
/> />
{jsonError && ( {jsonError && (
<p className="help is-danger json-error">{jsonError}</p> <p className="font-mono text-xs text-destructive">{jsonError}</p>
)} )}
</div> </div>
) : ( ) : (
@@ -286,25 +305,24 @@ const MarkdownEditor = () => {
/> />
)} )}
</div> </div>
</div>
<div className="field"> <div>
<div className="control"> <Button
<button
className="button is-primary"
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={saveMarkdown.isLoading} disabled={saveMarkdown.isLoading}
> >
<Save className="h-4 w-4" />
{saveMarkdown.isLoading ? "Saving..." : "Save"} {saveMarkdown.isLoading ? "Saving..." : "Save"}
</button> </Button>
</div>
</div> </div>
</form> </form>
</div> </div>
<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">
Preview
</h3>
<MarkdownView <MarkdownView
content={content} content={content}
template={!markdown?.id ? selectedTemplate : template} template={!markdown?.id ? selectedTemplate : template}

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: "JetBrains Mono", ui-monospace, monospace;
font-weight: 600;
color: hsl(var(--foreground));
line-height: 1.3;
margin: 2rem 0 0.85rem;
scroll-margin-top: 5rem;
}
.markdown-preview h1 {
font-size: 1.9rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid hsl(var(--border));
}
.markdown-preview h1::before,
.markdown-preview h2::before {
content: "# ";
color: hsl(var(--primary) / 0.6);
}
.markdown-preview h2 {
font-size: 1.5rem;
}
.markdown-preview h3 {
font-size: 1.25rem;
color: hsl(var(--foreground) / 0.9);
}
.markdown-preview h4 {
font-size: 1.05rem;
}
.markdown-preview h5,
.markdown-preview h6 {
font-size: 0.95rem;
color: hsl(var(--muted-foreground));
} }
.markdown-preview ul, .markdown-preview 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: "JetBrains 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: "JetBrains 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: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--muted-foreground));
}
.markdown-preview tr:hover td {
background: hsl(var(--accent) / 0.5);
}
/* KaTeX on dark */
.katex-display {
margin: 1.25em 0;
text-align: center;
overflow-x: auto;
overflow-y: hidden;
}
.katex {
font-size: 1.1rem;
color: hsl(var(--foreground));
} }

View File

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

View File

@@ -9,6 +9,8 @@ import { useMarkdownTemplate } from "../../utils/queries/markdown-template-queri
import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-template-setting-queries"; import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-template-setting-queries";
import { useTree } from "../../utils/queries/tree-queries"; import { useTree } from "../../utils/queries/tree-queries";
import { getMarkdownIdByPath } from "../../utils/pathUtils"; import { getMarkdownIdByPath } from "../../utils/pathUtils";
import { parseMarkdownContent } from "../../utils/safe-json";
import { Spinner } from "../ui/misc";
const StandaloneMarkdownPage = () => { const StandaloneMarkdownPage = () => {
const location = useLocation(); const location = useLocation();
@@ -52,46 +54,55 @@ const StandaloneMarkdownPage = () => {
if (notReady) { if (notReady) {
return ( return (
<div style={{ padding: "2rem", textAlign: "center" }}> <div className="flex min-h-screen items-center justify-center">
<div>Loading...</div> <Spinner label="Loading" />
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div style={{ padding: "2rem", textAlign: "center" }}> <div className="flex min-h-screen items-center justify-center px-6">
<div>Error: {error.message || "Failed to load content"}</div> <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> </div>
); );
} }
if (!notReady && !markdownId) { if (!notReady && !markdownId) {
return ( return (
<div style={{ padding: "2rem", textAlign: "center" }}> <div className="flex min-h-screen items-center justify-center px-6">
<div>Markdown not found for path: {pathString}</div> <p className="font-mono text-sm text-muted-foreground">
Markdown not found for path:{" "}
<span className="text-foreground">{pathString}</span>
</p>
</div> </div>
); );
} }
if (markdown?.isMessage) { if (markdown?.isMessage) {
return ( return (
<div style={{ padding: "2rem" }}> <div className="mx-auto max-w-3xl px-6 py-12">
<div className="notification is-info"> <div className="rounded-lg border border-primary/30 bg-primary/10 px-5 py-4">
<h4 className="title is-4">{markdown.title}</h4> <h4 className="mb-1 font-mono text-base font-semibold text-primary">
<p>{markdown.content}</p> {markdown.title}
</h4>
<p className="text-sm text-foreground/80">
{markdown.content}
</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div style={{ padding: "2rem", maxWidth: "100%", margin: "0 auto" }}> <div className="relative z-10 mx-auto max-w-4xl px-6 py-12">
<div style={{ marginBottom: "2rem" }}> <h1 className="mb-8 border-b border-border pb-4 font-mono text-3xl font-bold tracking-tight text-foreground">
<h1 className="title">{markdown?.title === "index" ? indexTitle : markdown?.title}</h1> {markdown?.title === "index" ? indexTitle : markdown?.title}
</div> </h1>
{markdown && ( {markdown && (
<MarkdownView content={JSON.parse(markdown.content)} template={template} /> <MarkdownView content={parseMarkdownContent(markdown.content)} template={template} />
)} )}
</div> </div>
); );

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,41 +18,34 @@ 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"
className="input"
value={value ?? ""} value={value ?? ""}
onChange={(e) => onContentChanged(variable.name, e.target.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"}}
className="textarea"
value={value} value={value}
onChange={(e) => onContentChanged(variable.name, e.target.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">
<div className="select is-fullwidth">
<select <select
className={SELECT_CLASS}
value={value} value={value}
onChange={(e) => onContentChanged(variable.name, e.target.value)} onChange={(e) => onContentChanged(variable.name, e.target.value)}
> >
@@ -52,8 +54,6 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
))} ))}
</select> </select>
</div> </div>
</div>
</div>
); );
case "list": { case "list": {
@@ -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
className="button is-danger"
type="button" type="button"
variant="destructive"
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">
<button
className="button is-warning"
type="button" type="button"
variant="outline"
size="sm"
onClick={addItem} onClick={addItem}
> >
ADD <Plus className="h-4 w-4" /> 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}

View File

@@ -1,8 +1,21 @@
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']; const AVAILABLE_ROLES = ['guest', 'creator', 'admin'];
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50";
const ApiKeyCreationModal = ({ isOpen, onClose }) => { const ApiKeyCreationModal = ({ isOpen, onClose }) => {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [roles, setRoles] = useState(['guest']); const [roles, setRoles] = useState(['guest']);
@@ -59,38 +72,31 @@ const ApiKeyCreationModal = ({ 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>Create API Key</DialogTitle>
<p className="modal-card-title">Create API Key</p> </DialogHeader>
<button className="delete" aria-label="close" onClick={onClose}></button>
</header>
<section className="modal-card-body">
{!generatedKey ? ( {!generatedKey ? (
<div> <div className="space-y-5">
<div className="field"> <div className="space-y-2">
<label className="label">Name</label> <Label htmlFor="api-key-name">Name</Label>
<div className="control"> <Input
<input id="api-key-name"
className="input"
type="text" type="text"
placeholder="API key name" placeholder="API key name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
</div> </div>
</div> <div className="space-y-2">
<div className="field"> <Label>Roles</Label>
<label className="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">
<div className="select">
<select <select
className={SELECT_CLASS}
value={role} value={role}
onChange={(e) => handleRoleChange(index, e.target.value)} onChange={(e) => handleRoleChange(index, e.target.value)}
> >
@@ -100,64 +106,62 @@ const ApiKeyCreationModal = ({ isOpen, onClose }) => {
</option> </option>
))} ))}
</select> </select>
</div> <Button
</div> variant="destructive"
<div className="control"> size="icon"
<button
className="button is-danger"
onClick={() => handleRemoveRole(index)} onClick={() => handleRemoveRole(index)}
disabled={roles.length === 1} disabled={roles.length === 1}
> >
Delete <Trash2 className="h-4 w-4" />
</button> </Button>
</div>
</div> </div>
))} ))}
<button </div>
className="button is-info mt-2" <Button
variant="outline"
size="sm"
onClick={handleAddRole} onClick={handleAddRole}
disabled={roles.length === AVAILABLE_ROLES.length} disabled={roles.length === AVAILABLE_ROLES.length}
> >
Add Role <Plus className="h-4 w-4" /> Add Role
</button> </Button>
</div> </div>
</div> </div>
) : ( ) : (
<div> <div className="space-y-4">
<div className="notification is-warning"> <div className="rounded-lg border border-secondary/40 bg-secondary/10 px-4 py-3 font-mono text-sm text-secondary">
Please copy your API key immediately! It will only be displayed once! Please copy your API key immediately! It will only be displayed once!
</div> </div>
<div className="field"> <div className="space-y-2">
<label className="label">Your API Key:</label> <Label htmlFor="generated-api-key">Your API Key</Label>
<div className="control"> <Input
<input id="generated-api-key"
className="input" className="font-mono"
type="text" type="text"
value={generatedKey.key} value={generatedKey.key}
readOnly readOnly
/> />
</div> </div>
</div> <Button onClick={handleCopy}>
<button className="button is-info" onClick={handleCopy}> <Copy className="h-4 w-4" /> Copy
Copy </Button>
</button>
</div> </div>
)} )}
</section> <DialogFooter>
<footer className="modal-card-foot"> <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 || !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>
); );
}; };

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,42 +31,36 @@ 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>
<div className="control">
<input
className="input"
type="text" type="text"
placeholder="Enter API key to revoke" placeholder="Enter API key to revoke"
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
/> />
</div> </div>
</div> <DialogFooter>
</section> <Button variant="outline" onClick={onClose}>
<footer className="modal-card-foot"> Cancel
<button </Button>
className="button is-danger" <Button
variant="destructive"
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>
); );
}; };

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,36 +16,26 @@ 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">
<div className="field">
<div className="control">
<textarea
className="textarea"
value={JSON.stringify(schema, null, 2)} value={JSON.stringify(schema, null, 2)}
readOnly readOnly
style={{ height: "50vh" }}
/> />
</div> <DialogFooter>
</div> <Button variant="outline" onClick={onClose}>
</section> Close
<footer className="modal-card-foot"> </Button>
<button className="button is-primary" onClick={handleCopy}> <Button onClick={handleCopy}>
<span className="icon"> <Copy className="h-4 w-4" /> Copy
<i className="fas fa-copy"></i> </Button>
</span> </DialogFooter>
<span>copy</span> </DialogContent>
</button> </Dialog>
<button className="button" onClick={onClose}>close</button>
</footer>
</div>
</div>
); );
}; };

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">
type="button" <Spinner label="Loading settings" />
aria-label="close"
onClick={onClose}
/>
</header>
{
markdownSetting ? (
<section className="modal-card-body">
<div className="tabs">
<ul>
<li className={activeTab==="template" ? "is-active" : ""}>
<a onClick={() => setActiveTab("template")}>Template</a>
</li>
<li className={activeTab==="permission" ? "is-active" : ""}>
<a onClick={() => setActiveTab("permission")}>Permission</a>
</li>
</ul>
</div> </div>
{activeTab === "template" && ( ) : markdownSetting ? (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="template">Template</TabsTrigger>
<TabsTrigger value="permission">Permission</TabsTrigger>
</TabsList>
<TabsContent value="template">
<MarkdownTemplateSettingPanel <MarkdownTemplateSettingPanel
markdownSetting={markdownSetting} markdownSetting={markdownSetting}
onClose={onClose} onClose={onClose}
/> />
)} </TabsContent>
{activeTab === "permission" && ( <TabsContent value="permission">
<MarkdownPermissionSettingPanel <MarkdownPermissionSettingPanel
markdownSetting={markdownSetting} markdownSetting={markdownSetting}
onClose={onClose} onClose={onClose}
/> />
)} </TabsContent>
</section> </Tabs>
) : ( ) : (
<section className="modal-card-body"> <Button
<button
className="button is-primary"
type="button" type="button"
onClick={handleCreateMarkdownSetting} onClick={handleCreateMarkdownSetting}
> >
Create Markdown Setting <Plus className="h-4 w-4" /> Create Markdown Setting
</button> </Button>
</section> )}
) </DialogContent>
} </Dialog>
</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"
aria-label="close"
onClick={onClose}
/>
</header>
{!pathSetting ? (
<section className="modal-card-body">
<button
type="button"
className="button is-primary"
onClick={handleCreatePathSetting} onClick={handleCreatePathSetting}
> >
Create Path Setting <Plus className="h-4 w-4" /> Create Path Setting
</button> </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>
</Tabs>
)} )}
</section> </DialogContent>
)} </Dialog>
</div>
</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>
<a
role="button"
className="navbar-burger"
aria-label="menu"
aria-expanded="false"
data-target="navbarBasicExample"
onClick={(e) => {
e.currentTarget.classList.toggle("is-active");
document
.getElementById("navbarBasicExample")
.classList.toggle("is-active");
}}
> >
<span aria-hidden="true"></span> <img
<span aria-hidden="true"></span> src="/icons/logo.png"
<span aria-hidden="true"></span> alt="Logo"
</a> className="h-7 w-7 rounded"
</div> />
<span className="font-mono text-sm font-semibold tracking-tight">
<span className="text-foreground">HANGMAN</span>
<span className="neon-text">//LAB</span>
</span>
</Link>
<div id="navbarBasicExample" className="navbar-menu"> <nav className="ml-2 hidden items-center gap-1 sm:flex">
<div className="navbar-start">
<a <a
href="https://mail.hangman-lab.top" href="https://mail.hangman-lab.top"
className="navbar-item"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
> >
MailBox <Mail className="h-3.5 w-3.5" /> Mail
</a> </a>
<a <a
href="https://git.hangman-lab.top" href="https://git.hangman-lab.top"
className="navbar-item"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
> >
Git <GitBranch className="h-3.5 w-3.5" /> Git
</a> </a>
</div> </nav>
<div className="navbar-end"> <div className="ml-auto flex items-center">
{user && user.profile ? ( {user && user.profile ? (
<div className="navbar-item has-dropdown is-hoverable"> <DropdownMenu>
<div className="buttons"> <DropdownMenuTrigger asChild>
<span <Button
className="button is-primary is-light" variant="outline"
onClick={() => setIsDropdownOpen(!isDropdownOpen)} size="sm"
className="gap-2"
> >
<span className="grid h-5 w-5 place-items-center rounded-full bg-primary/15 font-mono text-[10px] font-bold text-primary">
{(user.profile.name || "?")
.charAt(0)
.toUpperCase()}
</span>
<span className="max-w-[10rem] truncate">
{user.profile.name} {user.profile.name}
</span> </span>
<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
className="button is-info dropdown-option"
onClick={() => setIsApiKeyModalOpen(true)} onClick={() => setIsApiKeyModalOpen(true)}
type="button"
> >
Create API Key <KeyRound /> Create API Key
</button> </DropdownMenuItem>
<button <DropdownMenuItem
className="button is-warning dropdown-option"
onClick={() => setIsRevokeModalOpen(true)} onClick={() => setIsRevokeModalOpen(true)}
type="button"
> >
Revoke API Key <KeySquare /> Revoke API Key
</button> </DropdownMenuItem>
<button <DropdownMenuSeparator />
className="button is-danger dropdown-option" <DropdownMenuItem
onClick={logout} onClick={logout}
type="button" className="text-destructive focus:text-destructive [&_svg]:text-destructive"
> >
Logout <LogOut /> Logout
</button> </DropdownMenuItem>
</div> </DropdownMenuContent>
</div> </DropdownMenu>
</div>
) : ( ) : (
<div className="navbar-item"> <Button size="sm" onClick={login} className="gap-2">
<button <LogIn className="h-4 w-4" /> Login
className="button is-primary" </Button>
onClick={login}
type="button"
>
Login
</button>
</div>
)} )}
</div> </div>
</div> </header>
</nav>
<ApiKeyCreationModal <ApiKeyCreationModal
isOpen={isApiKeyModalOpen} isOpen={isApiKeyModalOpen}
onClose={() => setIsApiKeyModalOpen(false)} onClose={() => setIsApiKeyModalOpen(false)}

View File

@@ -1,10 +1,16 @@
import {Link, useNavigate} 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";
import {useDeleteMarkdown} from "../../utils/queries/markdown-queries"; import { useDeleteMarkdown } from "../../utils/queries/markdown-queries";
import {useDeleteMarkdownSetting} from "../../utils/queries/markdown-setting-queries"; import { useDeleteMarkdownSetting } from "../../utils/queries/markdown-setting-queries";
const MarkdownNode = ({markdown, handleMoveMarkdown}) => { 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 navigate = useNavigate();
const deleteMarkdown = useDeleteMarkdown(); const deleteMarkdown = useDeleteMarkdown();
@@ -14,10 +20,8 @@ const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
if (!window.confirm(`delete markdown "${markdown.title}" ? this action cannot be undone.`)) { if (!window.confirm(`delete markdown "${markdown.title}" ? this action cannot be undone.`)) {
return; return;
} }
try { try {
await deleteMarkdown.mutateAsync(markdown.id); await deleteMarkdown.mutateAsync(markdown.id);
if (window.location.pathname === `/markdown/${markdown.id}`) { if (window.location.pathname === `/markdown/${markdown.id}`) {
navigate('/'); navigate('/');
} }
@@ -25,57 +29,55 @@ const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
alert('failed: ' + (error.message || 'unknown 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
to={`/markdown/${markdown.id}`}
className="min-w-0 flex-1 truncate text-sm text-foreground/90 transition-colors hover:text-primary"
>
{markdown.title} {markdown.title}
</Link> </Link>
</span>
<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"> <Settings2 className="h-3.5 w-3.5" />
<i className="fas fa-cog"/>
</span>
</button> </button>
</p>
<p className="control">
<button <button
className="button is-small is-danger"
onClick={handleDeleteMarkdown}
type="button" type="button"
className={cn(iconBtn, "hover:text-destructive")}
title="Delete"
onClick={handleDeleteMarkdown}
disabled={deleteMarkdown.isLoading || deleteMarkdownSetting.isLoading} disabled={deleteMarkdown.isLoading || deleteMarkdownSetting.isLoading}
> >
<span className="icon"> <Trash2 className="h-3.5 w-3.5" />
<i className="fas fa-trash"/>
</span>
</button> </button>
</p> <div className="flex flex-col">
<div
className="control is-flex is-flex-direction-column is-align-items-center"
style={{marginLeft: "0.5rem"}}
>
<button <button
className="button is-small mb-1 move-forward" type="button"
style={{height: "1rem", padding: "0.25rem"}} className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
onClick={() => handleMoveMarkdown(markdown, "forward")} onClick={() => handleMoveMarkdown(markdown, "forward")}
type="button" 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={() => handleMoveMarkdown(markdown, "backward")}
type="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> </button>
</div> </div>
</div>
<MarkdownSettingModal <MarkdownSettingModal
isOpen={isMarkdownSettingModalOpen} isOpen={isMarkdownSettingModalOpen}
markdown={markdown} markdown={markdown}
@@ -83,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 ( return (
<li key={path.id}> <li>
<div className="path-node-header field has-addons"> <div className="group flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-accent/60">
<span className="control has-text-weight-bold path-toggle" onClick={isRoot ? undefined : toggleExpand}> <button
{isExpanded ? "-" : "+"} type="button"
</span> 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 ? ( {isEditing ? (
<div className="control has-icons-left">
<input <input
className="input is-small path-edit-input" autoFocus
className="h-6 min-w-0 flex-1 rounded border border-input bg-background/60 px-1.5 text-xs text-foreground focus:border-primary/60 focus:outline-none"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/> />
</div> ) : indexMarkdown ? (
// Clicking the name navigates to the folder's index page
) : ( // AND expands the subtree (expanded state is global, so
<span // the children stay visible after navigation).
className="path-name has-text-weight-bold control" <Link
onClick={isRoot ? undefined : expand} to={`/markdown/${indexMarkdown.id}`}
onClick={expand}
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground transition-colors hover:text-primary"
> >
{
indexMarkdown ? (
<Link to={`/markdown/${indexMarkdown.id}`} className="is-link index-path-node">
{path.name} {path.name}
</Link> </Link>
) : ( ) : (
<a className="is-link path-node">{path.name}</a> <span
) onClick={toggleExpand}
} className="min-w-0 flex-1 cursor-pointer truncate text-sm font-medium text-foreground"
>
{path.name}
</span> </span>
)} )}
<PermissionGuard rolesRequired={["admin"]}> <PermissionGuard rolesRequired={["admin"]}>
<div className="field has-addons actions control is-justify-content-flex-end"> <div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<p className="control">
<button <button
className="button is-small is-success"
onClick={() => {
setIsPathSettingModalOpen(true);
}}
type="button" type="button"
className={iconBtn}
title="Settings"
onClick={() => setIsPathSettingModalOpen(true)}
> >
<span className="icon"> <Settings2 className="h-3.5 w-3.5" />
<i className="fas fa-cog"/>
</span>
</button> </button>
</p>
{isEditing ? ( {isEditing ? (
<p className="control">
<button <button
className="button is-small is-success" type="button"
className={iconBtn}
title="Save"
onClick={handleSave} onClick={handleSave}
type="button"
> >
<span className="icon"> <Check className="h-3.5 w-3.5 text-primary" />
<i className="fas fa-check"></i>
</span>
</button> </button>
</p>
) : ( ) : (
<p className="control">
<button <button
className="button is-small is-info" type="button"
className={iconBtn}
title="Rename"
onClick={handleEdit} onClick={handleEdit}
type="button"
> >
<span className="icon"> <Pencil className="h-3.5 w-3.5" />
<i className="fas fa-pen"></i>
</span>
</button> </button>
</p>
)} )}
<p className="control">
<button <button
className="button is-danger is-small" type="button"
className={cn(iconBtn, "hover:text-destructive")}
title="Delete"
onClick={handleDelete} onClick={handleDelete}
type="button">
<span className="icon">
<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"}}
> >
<Trash2 className="h-3.5 w-3.5" />
</button>
<div className="flex flex-col">
<button <button
className="button is-small mb-1 move-forward" type="button"
style={{height: "1rem", padding: "0.25rem"}} className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
onClick={() => handleMovePath(path, "forward")} onClick={() => handleMovePath(path, "forward")}
type="button" 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,54 +115,60 @@ 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"> >
<LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-secondary/80" />
<span className="min-w-0 flex-1 truncate text-sm text-foreground/90">
{template.title}
</span>
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<button <button
className="button is-small control" type="button"
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
title="Edit"
onClick={() => handleTemplateClick(template.id)} onClick={() => handleTemplateClick(template.id)}
type="button"
> >
<span className="icon"> <Pencil className="h-3.5 w-3.5" />
<i className="fas fa-edit"></i>
</span>
</button> </button>
<button <button
className="button is-small control"
onClick={() => setSelectedSchema(generateJsonSchema(template))}
type="button" type="button"
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
title="JSON schema"
onClick={() => setSelectedSchema(generateJsonSchema(template))}
> >
<span className="icon"> <Braces className="h-3.5 w-3.5" />
<i className="fas fa-code"></i>
</span>
</button> </button>
</div> </div>
</div>
</li> </li>
))} ))}
</ul> </ul>
@@ -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,8 +92,8 @@ const TreeTab = () => {
onSave={handleSave} onSave={handleSave}
onDelete={handleDelete} onDelete={handleDelete}
/> />
} )}
</aside> </div>
); );
}; };

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) => (
<span <button
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>
<div className="control">
<span>&nbsp;&nbsp;&nbsp;&nbsp;</span>
</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>
<div className="control">
<button
className="button is-small is-primary"
onClick={handleAddDirectory}
disabled={isSubPathsLoading || !searchTerm.trim()}
type="button"
>
Create "{searchTerm}"
</button>
</div>
</div>
{dropdownActive && ( {dropdownActive && (
<div className="dropdown is-active"> <div className="path-manager-dropdown absolute left-0 top-full z-20 mt-1 w-full overflow-y-auto rounded-md border border-border bg-card shadow-glow">
<div className="dropdown-menu">
<div className="dropdown-content">
{filteredSubPaths.length > 0 ? ( {filteredSubPaths.length > 0 ? (
filteredSubPaths.map((subPath) => ( filteredSubPaths.map((subPath) => (
<a <button
type="button"
key={subPath.id} key={subPath.id}
className="dropdown-item" className="block w-full px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-accent"
onClick={() => handleSubPathSelect(subPath)} onClick={() => handleSubPathSelect(subPath)}
> >
{subPath.name} {subPath.name}
</a> </button>
)) ))
) : ( ) : (
<div className="dropdown-item">No matches found</div> <div className="px-3 py-2 text-xs text-muted-foreground">
)} No matches found
</div>
</div>
</div> </div>
)} )}
{isSubPathsLoading && <p>Loading...</p>} </div>
{subPathsError && <p>Error loading subdirectories.</p>} )}
</div>
<Button
type="button"
size="sm"
onClick={handleAddDirectory}
disabled={isSubPathsLoading || !searchTerm.trim()}
>
<Plus className="h-4 w-4" /> Create "{searchTerm}"
</Button>
</div>
{isSubPathsLoading && <Spinner label="Loading paths" />}
{subPathsError && (
<p className="font-mono text-xs text-destructive">
Error loading subdirectories.
</p>
)}
</div> </div>
</div> </div>
); );

View File

@@ -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,16 +56,23 @@ 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">
<Label htmlFor="permission-select">Permission</Label>
<select <select
id="permission-select"
className={SELECT_CLASS}
value={permission} value={permission}
onChange={(e) => setPermission(e.target.value)} onChange={(e) => setPermission(e.target.value)}
> >
@@ -68,23 +82,20 @@ const MarkdownPermissionSettingPanel = ({markdownSetting, onClose}) => {
<option value="private">private</option> <option value="private">private</option>
</select> </select>
</div> </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,15 +58,22 @@ 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">
<Label htmlFor="use-template-select">Use Template</Label>
<select <select
id="use-template-select"
className={SELECT_CLASS}
value={selectedTemplateId} value={selectedTemplateId}
onChange={(e) => { onChange={(e) => {
setSelectedTemplateId(e.target.value); setSelectedTemplateId(e.target.value);
@@ -72,23 +86,20 @@ const MarkdownTemplateSettingPanel = ({markdownSetting, onClose}) => {
</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,17 +232,19 @@ 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)}
> >
@@ -234,155 +255,114 @@ const WebhookSettingPanel = ({pathSetting, onClose}) => {
</option> </option>
))} ))}
</select> </select>
</div>
)} )}
</div> </div>
<div className="control"> <Button
<button
type="button" type="button"
className="button is-primary"
onClick={handleCreateWebhook} onClick={handleCreateWebhook}
> >
Add <Plus className="h-4 w-4" /> Add
</button> </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">
<input
type="checkbox"
checked={enabled} checked={enabled}
onChange={(e) => setEnabled(e.target.checked)} onChange={(e) => setEnabled(e.target.checked)}
label="Enabled"
/> />
&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">
<input
type="checkbox"
checked={isOnMarkdownCreated} checked={isOnMarkdownCreated}
onChange={(e) => onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked) handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked)
} }
label="Markdown Created"
/> />
&nbsp; Markdown Created <CheckboxRow
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnMarkdownUpdated} checked={isOnMarkdownUpdated}
onChange={(e) => onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked) handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked)
} }
label="Markdown Updated"
/> />
&nbsp; Markdown Updated <CheckboxRow
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnMarkdownDeleted} checked={isOnMarkdownDeleted}
onChange={(e) => onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked) handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked)
} }
label="Markdown Deleted"
/> />
&nbsp; Markdown Deleted
</label>
</div> </div>
<div className="column"> <div className="space-y-2">
<label className="checkbox"> <CheckboxRow
<input
type="checkbox"
checked={isOnPathCreated} checked={isOnPathCreated}
onChange={(e) => onChange={(e) =>
handleTriggerEventsUpdate("PATH_CREATED", e.target.checked) handleTriggerEventsUpdate("PATH_CREATED", e.target.checked)
} }
label="Path Created"
/> />
&nbsp; Path Created <CheckboxRow
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathUpdated} checked={isOnPathUpdated}
onChange={(e) => onChange={(e) =>
handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked) handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
} }
label="Path Updated"
/> />
&nbsp; Path Updated <CheckboxRow
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathDeleted} checked={isOnPathDeleted}
onChange={(e) => onChange={(e) =>
handleTriggerEventsUpdate("PATH_DELETED", e.target.checked) handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
} }
label="Path Deleted"
/> />
&nbsp; Path Deleted
</label>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="field"> <CheckboxRow
<label className="checkbox">
<input
type="checkbox"
checked={isRecursive} checked={isRecursive}
onChange={(e) => setIsRecursive(e.target.checked)} onChange={(e) => setIsRecursive(e.target.checked)}
label="Recursive"
/> />
&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"
className="input"
placeholder="key" placeholder="key"
value={h.key} value={h.key}
onChange={(e) => handleHeaderChange(idx, "key", e.target.value)} onChange={(e) => handleHeaderChange(idx, "key", e.target.value)}
/> />
</div> <Input
<div className="column">
<input
type="text" type="text"
className="input"
placeholder="value" placeholder="value"
value={h.value} value={h.value}
onChange={(e) => onChange={(e) =>
@@ -390,42 +370,41 @@ const WebhookSettingPanel = ({pathSetting, onClose}) => {
} }
/> />
</div> </div>
</div>
))} ))}
<button <div className="flex gap-2">
<Button
type="button" type="button"
className="button is-small is-info" variant="outline"
size="sm"
onClick={handleAddHeader} onClick={handleAddHeader}
> >
+ Header <Plus className="h-4 w-4" /> Header
</button> </Button>
<button <Button
type="button" type="button"
className="button is-small is-success" size="sm"
onClick={handleApplyHeaders} onClick={handleApplyHeaders}
style={{ marginLeft: "0.5rem" }}
> >
Apply <Check className="h-4 w-4" /> Apply
</button> </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 };

104
src/globals.css Normal file
View File

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

View File

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

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

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

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." };
}
}

74
tailwind.config.js Normal file
View File

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

View File

@@ -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'],
} }
] ]
}, },