Compare commits
10 Commits
1ce2eebbfa
...
fix/buildc
| Author | SHA1 | Date | |
|---|---|---|---|
| ba08bba7de | |||
| e91bea280b | |||
| 952387d50f | |||
| 045c7c51d6 | |||
| c9310250e4 | |||
| a08164e914 | |||
| 30a46d5064 | |||
| e5affe3465 | |||
| 101666d26d | |||
| 87b4246a9b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
summerizer.py
|
||||
summerizer.py
|
||||
node_modules
|
||||
|
||||
@@ -5,7 +5,12 @@ FRONTEND_HOST="${FRONTEND_HOST:-http://localhost:80}"
|
||||
KC_CLIENT_ID="${KC_CLIENT_ID:-labdev}"
|
||||
KC_HOST="${KC_HOST:-https://login.hangman-lab.top}"
|
||||
KC_REALM="${KC_REALM:-Hangman-Lab}"
|
||||
|
||||
# Note: ${DEBUG:-false} (correct default syntax). The old ${DEBUG:false}
|
||||
# produced an empty value when DEBUG was unset -> invalid config.json.
|
||||
DEBUG="${DEBUG:-false}"
|
||||
# DEBUG is emitted unquoted as a JSON boolean — guarantee it is exactly
|
||||
# true/false so config.json can never be invalid JSON.
|
||||
case "$DEBUG" in true|false) ;; *) DEBUG=false ;; esac
|
||||
rm -f /usr/share/nginx/html/config.js
|
||||
|
||||
|
||||
@@ -24,7 +29,8 @@ cat <<EOL > /usr/share/nginx/html/config.json
|
||||
"scope": "openid profile email roles",
|
||||
"popup_redirect_uri": "${FRONTEND_HOST}/popup_callback",
|
||||
"silent_redirect_uri": "${FRONTEND_HOST}/silent_callback"
|
||||
}
|
||||
},
|
||||
"DEBUG": ${DEBUG}
|
||||
}
|
||||
EOL
|
||||
|
||||
|
||||
1979
package-lock.json
generated
1979
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -12,9 +12,20 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@reduxjs/toolkit": "^2.7.0",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"assert": "^2.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"axios": "^1.7.9",
|
||||
"bulma": "^1.0.2",
|
||||
"katex": "^0.16.11",
|
||||
@@ -31,8 +42,10 @@
|
||||
"redux": "^5.0.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"util": "^0.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -42,10 +55,13 @@
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios-mock-adapter": "^2.1.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-loader": "^9.2.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"dotenv-webpack": "^8.1.0",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
@@ -55,6 +71,7 @@
|
||||
"sass": "^1.81.0",
|
||||
"sass-loader": "^16.0.3",
|
||||
"style-loader": "^4.0.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"webpack": "^5.96.1",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^5.1.0"
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Hangman Lab</title>
|
||||
<link rel="icon" type="image/png" href="./icons/logo.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
21
src/App.css
21
src/App.css
@@ -1,21 +1,4 @@
|
||||
.app-container {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Layout now lives in App.js via Tailwind; keep body from double-scrolling. */
|
||||
:root {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
64
src/App.js
64
src/App.js
@@ -6,44 +6,52 @@ import MainNavigation from "./components/Navigations/MainNavigation";
|
||||
import SideNavigation from "./components/Navigations/SideNavigation";
|
||||
import MarkdownContent from "./components/Markdowns/MarkdownContent";
|
||||
import MarkdownEditor from "./components/Markdowns/MarkdownEditor";
|
||||
import StandaloneMarkdownPage from "./components/Markdowns/StandaloneMarkdownPage";
|
||||
import "./App.css";
|
||||
import Callback from "./components/KeycloakCallbacks/Callback";
|
||||
import Footer from "./components/Footer";
|
||||
import PopupCallback from "./components/KeycloakCallbacks/PopupCallback";
|
||||
import SilentCallback from "./components/KeycloakCallbacks/SilentCallback";
|
||||
import MarkdownTemplateEditor from "./components/MarkdownTemplate/MarkdownTemplateEditor";
|
||||
import { TooltipProvider } from "./components/ui/misc";
|
||||
|
||||
const App = () => {
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Router>
|
||||
<div className="app-container">
|
||||
<MainNavigation />
|
||||
<div className="content-container">
|
||||
<SideNavigation />
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<Navigate to = "/markdown/1"/>}
|
||||
/>
|
||||
<Route path="/testx" element={<h2>test2</h2>}/>
|
||||
<Route path="/markdown/:id" element={<MarkdownContent />} />
|
||||
<Route path="/callback" element={<Callback />} />
|
||||
<Route path="/test" element={<h1>TEST</h1>}></Route>
|
||||
<Route path="/markdown/create" element={<MarkdownEditor />} />
|
||||
<Route path="/markdown/edit/:id" element={<MarkdownEditor />} />
|
||||
<Route path="/popup_callback" element={<PopupCallback />} />
|
||||
<Route path="/silent_callback" element={<SilentCallback />} />
|
||||
<Route path="/template/create" element={<MarkdownTemplateEditor />} />
|
||||
<Route path="/template/edit/:id" element={<MarkdownTemplateEditor />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</Router>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/pg/*" element={<StandaloneMarkdownPage />} />
|
||||
<Route path="*" element={
|
||||
<div className="relative z-10 flex h-screen flex-col">
|
||||
<MainNavigation />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<SideNavigation />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-5xl px-6 py-8 pb-28">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<Navigate to="/markdown/1" />}
|
||||
/>
|
||||
<Route path="/markdown/:strId" element={<MarkdownContent />} />
|
||||
<Route path="/callback" element={<Callback />} />
|
||||
<Route path="/markdown/create" element={<MarkdownEditor />} />
|
||||
<Route path="/markdown/edit/:strId" element={<MarkdownEditor />} />
|
||||
<Route path="/popup_callback" element={<PopupCallback />} />
|
||||
<Route path="/silent_callback" element={<SilentCallback />} />
|
||||
<Route path="/template/create" element={<MarkdownTemplateEditor />} />
|
||||
<Route path="/template/edit/:strId" element={<MarkdownTemplateEditor />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</TooltipProvider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
14
src/components/Debug/ControlledReactQueryDevtools.js
Normal file
14
src/components/Debug/ControlledReactQueryDevtools.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import {useConfig} from "../../ConfigProvider";
|
||||
import {ReactQueryDevtools} from "@tanstack/react-query-devtools";
|
||||
|
||||
export const ControlledReactQueryDevtools = () => {
|
||||
const config = useConfig();
|
||||
if(config.DEBUG)
|
||||
return (<ReactQueryDevtools />);
|
||||
|
||||
return (<></>);
|
||||
|
||||
};
|
||||
|
||||
export default ControlledReactQueryDevtools;
|
||||
@@ -1,82 +1,61 @@
|
||||
import React from "react";
|
||||
import "./Footer.css";
|
||||
import { ChevronUp, Mail, GitBranch, Linkedin } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const Footer = () => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [isVisible, setIsVisible] = React.useState(false);
|
||||
const toggleExpand = () => {
|
||||
if(!isExpanded) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
const onTransitionEnd = () => {
|
||||
if(!isExpanded) {
|
||||
setIsVisible(false);
|
||||
}
|
||||
};
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<footer className={`footer ${isExpanded ? "expanded" : ""}`}>
|
||||
<button
|
||||
className="toggle-button"
|
||||
onClick={toggleExpand}
|
||||
type="button"
|
||||
<footer className="glass fixed bottom-0 left-0 right-0 z-20 border-t border-border">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-300",
|
||||
open ? "max-h-20" : "max-h-0"
|
||||
)}
|
||||
>
|
||||
{isExpanded ? "↓" : "↑"}
|
||||
</button>
|
||||
<div className={`footer-content`}>
|
||||
<p>© {new Date().getFullYear()} Hangman Lab. {!isVisible && (<span>
|
||||
|
||||
<a href="mailto:hzhang@hangman-lab.top">email</a>
|
||||
|
||||
<a href="https://git.hangman-lab.top/hzhang/HangmanLab">git</a>
|
||||
|
||||
|
||||
</span>
|
||||
)}</p>
|
||||
{
|
||||
isVisible && (
|
||||
<div
|
||||
className={`footer-details ${isExpanded ? "expanded" : ""}`}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
>
|
||||
<div className="footer-icons">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/zhhrozhh/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="/icons/linkedin.png"
|
||||
alt="LinkedIn"
|
||||
className="footer-icon"
|
||||
/>
|
||||
LinkedIn
|
||||
</a>
|
||||
<a
|
||||
href="https://git.hangman-lab.top/hzhang/HangmanLab"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="/icons/git.png"
|
||||
alt="Git"
|
||||
className="footer-icon"
|
||||
/>
|
||||
Git
|
||||
</a>
|
||||
<a href="mailto:hzhang@hangman-lab.top">
|
||||
<img
|
||||
src="/icons/email.png"
|
||||
alt="Email"
|
||||
className="footer-icon"
|
||||
/>
|
||||
Email
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="flex items-center justify-center gap-6 py-3 text-xs">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/zhhrozhh/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<Linkedin className="h-3.5 w-3.5" /> LinkedIn
|
||||
</a>
|
||||
<a
|
||||
href="https://git.hangman-lab.top/hzhang/HangmanLab"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" /> Git
|
||||
</a>
|
||||
<a
|
||||
href="mailto:hzhang@hangman-lab.top"
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5" /> Email
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-9 items-center justify-between px-4">
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
© {new Date().getFullYear()}{" "}
|
||||
<span className="text-foreground/70">Hangman Lab</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-0.5 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronUp
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
open && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
{open ? "less" : "more"}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {useContext, useEffect} from "react";
|
||||
import { UserManager } from "oidc-client-ts";
|
||||
import {ConfigContext} from "../../ConfigProvider";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
|
||||
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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useContext } from "react";
|
||||
import { UserManager } from "oidc-client-ts";
|
||||
import {ConfigContext} from "../../ConfigProvider";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
|
||||
const PopupCallback = () => {
|
||||
@@ -18,7 +19,11 @@ const PopupCallback = () => {
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
return <div>Processing...</div>;
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner label="Processing" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupCallback;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useContext } from "react";
|
||||
import { UserManager } from "oidc-client-ts";
|
||||
import { ConfigContext } from "../../ConfigProvider";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
const SilentCallback = () => {
|
||||
const { config } = useContext(ConfigContext);
|
||||
@@ -15,7 +16,11 @@ const SilentCallback = () => {
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
return <div>Renew...</div>;
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner label="Renewing session" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SilentCallback;
|
||||
@@ -1,63 +1,55 @@
|
||||
import React, { useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const EnumsEditor = ({ enums, onChange }) => {
|
||||
const [_enums, setEnums] = useState(enums || []);
|
||||
|
||||
return (
|
||||
<div className="box">
|
||||
<ul>
|
||||
<div className="space-y-3 rounded-md border border-border bg-background/40 p-4">
|
||||
<ul className="space-y-2">
|
||||
{_enums.map((item, index) => (
|
||||
<li key={index} className="field has-addons" style={{ marginBottom: "0.5rem" }}>
|
||||
<div className="control is-expanded">
|
||||
<input
|
||||
className="input is-small"
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const updated = [..._enums];
|
||||
updated[index] = e.target.value;
|
||||
setEnums(updated);
|
||||
onChange(updated);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="control">
|
||||
<button
|
||||
className="button is-small is-danger"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = [..._enums];
|
||||
updated.splice(index, 1);
|
||||
setEnums(updated);
|
||||
onChange(updated);
|
||||
}}
|
||||
>
|
||||
<span className="icon is-small">
|
||||
<i className="fas fa-times" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const updated = [..._enums];
|
||||
updated[index] = e.target.value;
|
||||
setEnums(updated);
|
||||
onChange(updated);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
const updated = [..._enums];
|
||||
updated.splice(index, 1);
|
||||
setEnums(updated);
|
||||
onChange(updated);
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<button
|
||||
className="button is-small is-primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = [..._enums, ""];
|
||||
setEnums(updated);
|
||||
onChange(updated);
|
||||
}}
|
||||
>
|
||||
<span className="icon is-small">
|
||||
<i className="fas fa-plus" />
|
||||
</span>
|
||||
<span>Add Enum</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const updated = [..._enums, ""];
|
||||
setEnums(updated);
|
||||
onChange(updated);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Add Enum
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import { Textarea } from '../ui/input';
|
||||
const LayoutEditor = ({layout, onChange}) => {
|
||||
const [_layout, setLayout] = useState(layout || "");
|
||||
useEffect(() => {setLayout(layout)}, [layout]);
|
||||
return (
|
||||
<textarea
|
||||
className="textarea"
|
||||
<Textarea
|
||||
className="h-[60vh] font-mono text-sm"
|
||||
value={_layout}
|
||||
onChange={(e) => {
|
||||
setLayout(e.target.value);
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import { AuthContext } from "../../AuthProvider";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Save } from "lucide-react";
|
||||
import { useMarkdownTemplate, useSaveMarkdownTemplate } from "../../utils/queries/markdown-template-queries";
|
||||
import LayoutEditor from "./LayoutEditor";
|
||||
import ParametersManager from "./ParametersManager";
|
||||
import "bulma/css/bulma.min.css";
|
||||
import { Input, Label } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
const MarkdownTemplateEditor = () => {
|
||||
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { strId } = useParams();
|
||||
const id = Number(strId);
|
||||
const { data: template, isFetching: templateIsFetching } = useMarkdownTemplate(id);
|
||||
const saveMarkdownTemplate = useSaveMarkdownTemplate();
|
||||
|
||||
@@ -26,11 +30,19 @@ const MarkdownTemplateEditor = () => {
|
||||
}, [template]);
|
||||
|
||||
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"))
|
||||
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 = () => {
|
||||
saveMarkdownTemplate.mutate(
|
||||
{ id, data: { title, parameters, layout } },
|
||||
@@ -46,50 +58,48 @@ const MarkdownTemplateEditor = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h2 className="title is-4">Markdown Template Editor</h2>
|
||||
<div className="field">
|
||||
<label className="label">Title:</label>
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Enter template title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
<section className="mx-auto max-w-6xl px-6 py-8">
|
||||
<h2 className="mb-6 font-mono text-2xl font-bold tracking-tight text-foreground">
|
||||
Markdown Template Editor
|
||||
</h2>
|
||||
<div className="mb-6 space-y-2">
|
||||
<Label htmlFor="template-title">Title</Label>
|
||||
<Input
|
||||
id="template-title"
|
||||
type="text"
|
||||
placeholder="Enter template title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
|
||||
Layout
|
||||
</h3>
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<LayoutEditor
|
||||
layout={layout}
|
||||
parameters={parameters}
|
||||
onChange={(newLayout) => setLayout(newLayout)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="columns is-variable is-8">
|
||||
<div className="column">
|
||||
<h3 className="title is-5">Layout</h3>
|
||||
<div className="box">
|
||||
<LayoutEditor
|
||||
layout={layout}
|
||||
parameters={parameters}
|
||||
onChange={(newLayout) => setLayout(newLayout)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column">
|
||||
<h3 className="title is-5">Parameters</h3>
|
||||
<div className="box">
|
||||
<ParametersManager
|
||||
parameters={parameters}
|
||||
onChange={(newParameters) => setParameters(newParameters)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field is-grouped">
|
||||
<div className="control">
|
||||
<button className="button is-primary" onClick={handleSave}>
|
||||
Save Template
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
|
||||
Parameters
|
||||
</h3>
|
||||
<ParametersManager
|
||||
parameters={parameters}
|
||||
onChange={(newParameters) => setParameters(newParameters)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="h-4 w-4" /> Save Template
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import TypeEditor from "./TypeEditor";
|
||||
import { Input, Label } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const ParametersManager = ({ parameters, onChange }) => {
|
||||
const [_parameters, setParameters] = useState(parameters || []);
|
||||
const [expandedStates, setExpandedStates] = useState({});
|
||||
|
||||
const handleAdd = () => {
|
||||
const updated = [
|
||||
..._parameters,
|
||||
{
|
||||
name: "",
|
||||
type: { base_type: "string" }
|
||||
type: {
|
||||
base_type: "string",
|
||||
definition: {}
|
||||
}
|
||||
}
|
||||
];
|
||||
setParameters(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const handleTypeChange = (index, newType) => {
|
||||
const updated = [..._parameters];
|
||||
if (newType.base_type === "list" && !newType.extend_type) {
|
||||
newType.extend_type = {
|
||||
base_type: "string",
|
||||
definition: {}
|
||||
};
|
||||
}
|
||||
updated[index].type = newType;
|
||||
setParameters(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setParameters(parameters);
|
||||
}, [parameters]);
|
||||
@@ -33,53 +54,66 @@ const ParametersManager = ({ parameters, onChange }) => {
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const toggleExpand = (index) => {
|
||||
setExpandedStates(prev => ({
|
||||
...prev,
|
||||
[index]: !prev[index]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="box">
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<button className="button is-primary" onClick={handleAdd}>
|
||||
Add Parameter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{_parameters.map((param, index) => (
|
||||
<div key={index} className="box" style={{ marginBottom: "1rem" }}>
|
||||
<div className="field is-grouped is-grouped-multiline">
|
||||
<div className="control is-expanded">
|
||||
<label className="label">Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={param.name}
|
||||
onChange={(e) => handleNameChange(index, e.target.value)}
|
||||
placeholder="Parameter name"
|
||||
/>
|
||||
</div>
|
||||
<div className="control">
|
||||
<button
|
||||
className="button is-danger"
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-5">
|
||||
<Button type="button" onClick={handleAdd}>
|
||||
<Plus className="h-4 w-4" /> Add Parameter
|
||||
</Button>
|
||||
<div className="max-h-[50vh] space-y-3 overflow-y-auto">
|
||||
{_parameters.map((param, index) => (
|
||||
<div key={index} className="space-y-3 rounded-md border border-border bg-surface/40 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={param.name}
|
||||
onChange={(e) => handleNameChange(index, e.target.value)}
|
||||
placeholder="Parameter name"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(index)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Type</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => toggleExpand(index)}
|
||||
>
|
||||
{expandedStates[index] ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{expandedStates[index] && (
|
||||
<TypeEditor
|
||||
type={param.type}
|
||||
onChange={(newType) => handleTypeChange(index, newType)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Type:</label>
|
||||
<div className="control">
|
||||
<TypeEditor
|
||||
type={param.type}
|
||||
onChange={(newType) => {
|
||||
const updated = [..._parameters];
|
||||
updated[index].type = newType;
|
||||
setParameters(updated);
|
||||
onChange(updated);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useMarkdownTemplates } from "../../utils/queries/markdown-template-queries";
|
||||
import { Label } from "../ui/input";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
const TemplateSelector = ({ template, onChange }) => {
|
||||
const SELECT_CLASS =
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
|
||||
|
||||
const TemplateSelector = ({ template, onChange, onCreate }) => {
|
||||
const { data: templates, isFetching: templatesAreFetching } = useMarkdownTemplates();
|
||||
const [_template, setTemplate] = useState(
|
||||
templates?.find((t) => t.id === template?.id) || {
|
||||
@@ -22,36 +27,36 @@ const TemplateSelector = ({ template, onChange }) => {
|
||||
}, [template, templates]);
|
||||
|
||||
if (templatesAreFetching) {
|
||||
return <p>Loading...</p>;
|
||||
return <Spinner label="Loading templates" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="label">Select Template</label>
|
||||
<div className="control">
|
||||
<div className="select is-fullwidth is-primary">
|
||||
<select
|
||||
value={template?.id || ""}
|
||||
onChange={(e) => {
|
||||
const id = parseInt(e.target.value, 10);
|
||||
onChange(
|
||||
templates.find((t) => t.id === id) || {
|
||||
title: "",
|
||||
parameters: [],
|
||||
layout: "",
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">(None)</option>
|
||||
{templates.map((tmpl) => (
|
||||
<option key={tmpl.id} value={tmpl.id}>
|
||||
{tmpl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-selector">Select Template</Label>
|
||||
<select
|
||||
id="template-selector"
|
||||
className={SELECT_CLASS}
|
||||
value={template?.id || ""}
|
||||
onChange={(e) => {
|
||||
const id = parseInt(e.target.value, 10);
|
||||
const selectedTemplate = templates.find((t) => t.id === id) || {
|
||||
title: "",
|
||||
parameters: [],
|
||||
layout: "",
|
||||
};
|
||||
onChange(selectedTemplate);
|
||||
if (onCreate) {
|
||||
onCreate(selectedTemplate);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">(None)</option>
|
||||
{templates.map((tmpl) => (
|
||||
<option key={tmpl.id} value={tmpl.id}>
|
||||
{tmpl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import EnumsEditor from './EnumsEditor';
|
||||
import TemplateSelector from './TemplateSelector';
|
||||
import { Label, Textarea } from '../ui/input';
|
||||
|
||||
const SELECT_CLASS =
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
|
||||
|
||||
const TypeEditor = ({ type, onChange }) => {
|
||||
const [_type, setType] = React.useState(type || {});
|
||||
@@ -14,71 +18,44 @@ const TypeEditor = ({ type, onChange }) => {
|
||||
switch (_type.base_type) {
|
||||
case 'enum':
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="label">Enums</label>
|
||||
<div className="control">
|
||||
<EnumsEditor
|
||||
enums={_type.definition.enums}
|
||||
onChange={(newEnums) => {
|
||||
updateType({
|
||||
..._type,
|
||||
definition: { ..._type.definition, enums: newEnums },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Enums</Label>
|
||||
<EnumsEditor
|
||||
enums={_type.definition.enums}
|
||||
onChange={(newEnums) => {
|
||||
updateType({
|
||||
..._type,
|
||||
definition: { ..._type.definition, enums: newEnums },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'list':
|
||||
return (
|
||||
<div className="box">
|
||||
<div className="field">
|
||||
<label className="label">Extend Type</label>
|
||||
<div className="control">
|
||||
<TypeEditor
|
||||
type={_type.extend_type}
|
||||
onChange={(extendType) => {
|
||||
updateType({ ..._type, extend_type: extendType });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-md border border-border bg-background/40 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Extend Type</Label>
|
||||
<TypeEditor
|
||||
type={_type.extend_type}
|
||||
onChange={(extendType) => {
|
||||
updateType({ ..._type, extend_type: extendType });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Iter Layout</label>
|
||||
<div className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={_type.definition.iter_layout || ''}
|
||||
onChange={(e) => {
|
||||
updateType({
|
||||
..._type,
|
||||
definition: {
|
||||
..._type.definition,
|
||||
iter_layout: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'template':
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="label">Template</label>
|
||||
<div className="control">
|
||||
<TemplateSelector
|
||||
template={_type.definition.template}
|
||||
onChange={(newTemplate) => {
|
||||
<div className="space-y-2">
|
||||
<Label>Iter Layout</Label>
|
||||
<Textarea
|
||||
className="font-mono text-sm"
|
||||
value={_type.definition.iter_layout || ''}
|
||||
onChange={(e) => {
|
||||
updateType({
|
||||
..._type,
|
||||
definition: {
|
||||
..._type.definition,
|
||||
template: newTemplate,
|
||||
iter_layout: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -87,33 +64,43 @@ const TypeEditor = ({ type, onChange }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'template':
|
||||
return (
|
||||
<TemplateSelector
|
||||
template={_type.definition.template}
|
||||
onChange={(newTemplate) => {
|
||||
updateType({
|
||||
..._type,
|
||||
definition: {
|
||||
..._type.definition,
|
||||
template: newTemplate,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="box">
|
||||
<div className="field">
|
||||
<label className="label">Type</label>
|
||||
<div className="control">
|
||||
<div className="select is-fullwidth">
|
||||
<select
|
||||
value={_type.base_type || ''}
|
||||
onChange={(e) => {
|
||||
const updated = { base_type: e.target.value, definition: {} };
|
||||
updateType(updated);
|
||||
}}
|
||||
>
|
||||
<option value="string">string</option>
|
||||
<option value="markdown">markdown</option>
|
||||
<option value="enum">enum</option>
|
||||
<option value="list">list</option>
|
||||
<option value="template">template</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 rounded-md border border-border bg-surface/40 p-4">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
value={_type.base_type || ''}
|
||||
onChange={(e) => {
|
||||
const updated = { base_type: e.target.value, definition: {} };
|
||||
updateType(updated);
|
||||
}}
|
||||
>
|
||||
<option value="string">string</option>
|
||||
<option value="markdown">markdown</option>
|
||||
<option value="enum">enum</option>
|
||||
<option value="list">list</option>
|
||||
<option value="template">template</option>
|
||||
</select>
|
||||
{renderExtraFields()}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,42 +1 @@
|
||||
.markdown-content-container {
|
||||
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;
|
||||
}
|
||||
/* Styling now lives in the components via Tailwind + MarkdownView.css. */
|
||||
|
||||
@@ -2,17 +2,25 @@ import React, { useEffect, useState } from "react";
|
||||
import {Link, useParams} from "react-router-dom";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./MarkdownContent.css";
|
||||
import { Settings2, Pencil } from "lucide-react";
|
||||
import MarkdownView from "./MarkdownView";
|
||||
import PatchCards from "./PatchCards";
|
||||
import PermissionGuard from "../PermissionGuard";
|
||||
import {useMarkdown} from "../../utils/queries/markdown-queries";
|
||||
import {usePath} from "../../utils/queries/path-queries";
|
||||
import {useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
|
||||
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
|
||||
import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
|
||||
import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
|
||||
import { parseMarkdownContent } from "../../utils/safe-json";
|
||||
import { Button } from "../ui/button";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
const MarkdownContent = () => {
|
||||
const { id } = useParams();
|
||||
const { strId } = useParams();
|
||||
const id = Number(strId);
|
||||
const [indexTitle, setIndexTitle] = useState(null);
|
||||
const [isSettingModalOpen, setSettingModalOpen] = useState(false);
|
||||
const {data: markdown, isLoading, error} = useMarkdown(id);
|
||||
const {data: path, isFetching: isPathFetching} = usePath(markdown?.path_id);
|
||||
const {data: setting, isFetching: isSettingFetching} = useMarkdownSetting(markdown?.setting_id);
|
||||
@@ -29,36 +37,62 @@ const MarkdownContent = () => {
|
||||
const notReady = isLoading || isPathFetching || isSettingFetching || isTemplateSettingFetching || isTemplateFetching;
|
||||
|
||||
if (notReady) {
|
||||
return <div>Loading...</div>;
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<Spinner label="Loading content" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message || "Failed to load content"}</div>;
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
|
||||
Error: {error.message || "Failed to load content"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (markdown.isMessage) {
|
||||
return (
|
||||
<div className="markdown-content-container">
|
||||
<div className="notification is-info">
|
||||
<h4 className="title is-4">{markdown.title}</h4>
|
||||
<p>{markdown.content}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/10 px-5 py-4">
|
||||
<h4 className="mb-1 font-mono text-base font-semibold text-primary">
|
||||
{markdown.title}
|
||||
</h4>
|
||||
<p className="text-sm text-foreground/80">{markdown.content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="markdown-content-container">
|
||||
<div className="field has-addons markdown-content-container-header">
|
||||
<h1 className="title control">{markdown.title === "index" ? indexTitle : markdown.title}</h1>
|
||||
<article>
|
||||
<div className="mb-6 flex items-start justify-between gap-4 border-b border-border pb-4">
|
||||
<h1 className="font-mono text-3xl font-bold tracking-tight text-foreground">
|
||||
{markdown.title === "index" ? indexTitle : markdown.title}
|
||||
</h1>
|
||||
<PermissionGuard rolesRequired={['admin']}>
|
||||
<Link to={`/markdown/edit/${id}`} className="control button is-primary is-light">
|
||||
Edit
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSettingModalOpen(true)}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" /> Settings
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link to={`/markdown/edit/${id}`}>
|
||||
<Pencil className="h-4 w-4" /> Edit
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
<MarkdownView content={JSON.parse(markdown.content)} template={template}/>
|
||||
</div>
|
||||
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template}/>
|
||||
<PatchCards markdownId={id} />
|
||||
<MarkdownSettingModal
|
||||
isOpen={isSettingModalOpen}
|
||||
markdown={markdown}
|
||||
onClose={() => setSettingModalOpen(false)}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,110 +1,34 @@
|
||||
|
||||
.markdown-editor-container {
|
||||
max-width: 90vw;
|
||||
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;
|
||||
}
|
||||
/* Layout/form styling moved to Tailwind / dark-tech design system in
|
||||
MarkdownEditor.js. Only KaTeX sizing and code/pre theming for rendered
|
||||
markdown remain here. */
|
||||
|
||||
.katex-display {
|
||||
margin: 1em 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.katex {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
background-color: #f4f4f4;
|
||||
padding: 2px 4px;
|
||||
font-family: ui-monospace, "JetBrains Mono", "Courier New", Courier, monospace;
|
||||
background-color: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 10px;
|
||||
background-color: hsl(var(--surface));
|
||||
color: hsl(var(--foreground));
|
||||
padding: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.raw-editor {
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.editor-toggle-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.json-error {
|
||||
color: red;
|
||||
margin-top: 5px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,17 @@ import {useMarkdownTemplate, useMarkdownTemplates} from "../../utils/queries/mar
|
||||
import TemplatedEditor from "./TemplatedEditor";
|
||||
import {useMarkdownTemplateSetting, useUpdateMarkdownTemplateSetting, useCreateMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
|
||||
import TemplateSelector from "../MarkdownTemplate/TemplateSelector";
|
||||
import {useCreateMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
|
||||
import { Save, Code, LayoutTemplate } from "lucide-react";
|
||||
import { Input, Textarea, Label } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
const MarkdownEditor = () => {
|
||||
const { roles } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { strId } = useParams();
|
||||
const id = Number(strId);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState({});
|
||||
const [shortcut, setShortcut] = useState("");
|
||||
@@ -23,6 +29,7 @@ const MarkdownEditor = () => {
|
||||
const [isRawMode, setIsRawMode] = useState(false);
|
||||
const [rawContent, setRawContent] = useState("");
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
const {data: markdown, isFetching: isMarkdownFetching, error} = useMarkdown(id);
|
||||
const saveMarkdown = useSaveMarkdown();
|
||||
const {data: setting, isFetching: isSettingFetching} = useMarkdownSetting(markdown?.setting_id);
|
||||
@@ -32,10 +39,12 @@ const MarkdownEditor = () => {
|
||||
const createTemplateSetting = useCreateMarkdownTemplateSetting();
|
||||
const updateSetting = useUpdateMarkdownSetting();
|
||||
const {data: templates, isFetching: templatesAreFetching} = useMarkdownTemplates();
|
||||
const createMarkdownSetting = useCreateMarkdownSetting();
|
||||
|
||||
const notReady = isMarkdownFetching || isTemplateFetching || isSettingFetching || isTemplateSettingFetching || templatesAreFetching;
|
||||
|
||||
useEffect(() => {
|
||||
if(markdown){
|
||||
if (markdown) {
|
||||
setTitle(markdown.title);
|
||||
if (markdown.isMessage) {
|
||||
navigate("/");
|
||||
@@ -56,22 +65,84 @@ const MarkdownEditor = () => {
|
||||
}
|
||||
}, [markdown, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setSelectedTemplate(template);
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (isRawMode && jsonError) {
|
||||
alert("Please fix the JSON errors before saving");
|
||||
return;
|
||||
}
|
||||
|
||||
saveMarkdown.mutate(
|
||||
{id, data: {title, content: JSON.stringify(content), path_id: pathId, shortcut}},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate("/");
|
||||
},
|
||||
onError: () => {
|
||||
alert("Error saving markdown file");
|
||||
const saveData = {
|
||||
title,
|
||||
content: JSON.stringify(content),
|
||||
path_id: pathId,
|
||||
shortcut
|
||||
};
|
||||
console.log("markdown", markdown);
|
||||
console.log(markdown?.id ? "update" : "create",)
|
||||
if (!markdown?.id) {
|
||||
saveMarkdown.mutate(
|
||||
{data: saveData},
|
||||
{
|
||||
onSuccess: (newMarkdown) => {
|
||||
createMarkdownSetting.mutate({}, {
|
||||
onSuccess: (settingRes) => {
|
||||
saveMarkdown.mutate({
|
||||
id: newMarkdown.id,
|
||||
data: {
|
||||
setting_id: settingRes.id
|
||||
}
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
if (selectedTemplate?.id) {
|
||||
createTemplateSetting.mutate({
|
||||
template_id: selectedTemplate.id
|
||||
}, {
|
||||
onSuccess: (templateSettingRes) => {
|
||||
updateSetting.mutate({
|
||||
id: settingRes.id,
|
||||
data: {
|
||||
template_setting_id: templateSettingRes.id
|
||||
}
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
navigate("/");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
alert("Error saving markdown file");
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
} else {
|
||||
console.log("try update");
|
||||
saveMarkdown.mutate(
|
||||
{id, data: saveData},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate("/markdown/" + id);
|
||||
},
|
||||
onError: () => {
|
||||
alert("Error saving markdown file");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEditMode = () => {
|
||||
@@ -104,57 +175,24 @@ const MarkdownEditor = () => {
|
||||
};
|
||||
|
||||
const handleTemplateChange = (newTemplate) => {
|
||||
if (!newTemplate) return;
|
||||
|
||||
setSelectedTemplate(newTemplate);
|
||||
if (templateSetting) {
|
||||
updateTemplateSetting.mutate(
|
||||
{
|
||||
id: templateSetting.id,
|
||||
data: { template_id: newTemplate.id }
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setContent({});
|
||||
},
|
||||
onError: () => {
|
||||
alert("Error updating template");
|
||||
}
|
||||
updateTemplateSetting.mutate({
|
||||
id: templateSetting.id,
|
||||
data: {
|
||||
template_id: newTemplate.id
|
||||
}
|
||||
);
|
||||
} else if (setting) {
|
||||
createTemplateSetting.mutate(
|
||||
{ template_id: newTemplate.id },
|
||||
{
|
||||
onSuccess: (newTemplateSetting) => {
|
||||
updateSetting.mutate(
|
||||
{
|
||||
id: setting.id,
|
||||
data: { template_setting_id: newTemplateSetting.id }
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setContent({});
|
||||
},
|
||||
onError: () => {
|
||||
alert("Error updating markdown setting");
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
alert("Error creating template setting");
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
alert("Cannot change template: No markdown setting found");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const hasPermission = roles.includes("admin") || roles.includes("creator");
|
||||
if (!hasPermission)
|
||||
return <div className="notification is-danger">Permission Denied</div>;
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
|
||||
Permission Denied
|
||||
</div>
|
||||
);
|
||||
|
||||
if(notReady) {
|
||||
console.log("=============");
|
||||
@@ -164,118 +202,132 @@ const MarkdownEditor = () => {
|
||||
console.log("isTemplateSettingFetching", isTemplateSettingFetching);
|
||||
console.log( "TemplatesAreFetching", templatesAreFetching);
|
||||
console.log("----------------");
|
||||
return <p>Loading...</p>;
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<Spinner label="Loading editor" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if(error)
|
||||
return <p>{error.message || "Failed to load markdown"}</p>;
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
|
||||
{error.message || "Failed to load markdown"}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="container mt-5 markdown-editor-container">
|
||||
<h2 className="title is-4">{id ? "Edit Markdown" : "Create Markdown"}</h2>
|
||||
<div className="columns">
|
||||
<div className="column is-half">
|
||||
<form>
|
||||
<div className="field">
|
||||
<label className="label">Title</label>
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Enter title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="markdown-editor-container mx-auto max-w-[90vw] px-6 py-8">
|
||||
<h2 className="mb-6 font-mono text-2xl font-bold tracking-tight text-foreground">
|
||||
{id ? "Edit Markdown" : "Create Markdown"}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<form className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="md-title">Title</Label>
|
||||
<Input
|
||||
id="md-title"
|
||||
type="text"
|
||||
placeholder="Enter title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Shortcut</label>
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="Enter shortcut"
|
||||
value={shortcut}
|
||||
onChange={(e) => setShortcut(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="md-shortcut">Shortcut</Label>
|
||||
<Input
|
||||
id="md-shortcut"
|
||||
type="text"
|
||||
placeholder="Enter shortcut"
|
||||
value={shortcut}
|
||||
onChange={(e) => setShortcut(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Path</label>
|
||||
<div className="space-y-2">
|
||||
<Label>Path</Label>
|
||||
<PathManager
|
||||
currentPathId={pathId}
|
||||
onPathChange={setPathId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<TemplateSelector
|
||||
template={template}
|
||||
onChange={handleTemplateChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TemplateSelector
|
||||
template={selectedTemplate || template}
|
||||
onChange={handleTemplateChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<div className="is-flex is-justify-content-space-between is-align-items-center mb-2">
|
||||
<label className="label mb-0">Content</label>
|
||||
<button
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Content</Label>
|
||||
<Button
|
||||
type="button"
|
||||
className={`button is-small editor-toggle-button ${isRawMode ? 'is-info' : 'is-light'}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleEditMode}
|
||||
>
|
||||
{isRawMode ? 'Switch to Template Editor' : 'Switch to Raw Editor'}
|
||||
</button>
|
||||
{isRawMode ? (
|
||||
<>
|
||||
<LayoutTemplate className="h-4 w-4" /> Switch to Template Editor
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Code className="h-4 w-4" /> Switch to Raw Editor
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="control">
|
||||
{isRawMode ? (
|
||||
<div>
|
||||
<p className="help mb-2">
|
||||
Edit the JSON directly. Make sure it's valid JSON before saving.
|
||||
</p>
|
||||
<textarea
|
||||
className={`textarea raw-editor ${jsonError ? 'is-danger' : ''}`}
|
||||
style={{height: "70vh"}}
|
||||
value={rawContent}
|
||||
onChange={handleRawContentChange}
|
||||
placeholder="Enter JSON content here"
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="help is-danger json-error">{jsonError}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TemplatedEditor
|
||||
style={{height: "70vh"}}
|
||||
content={content}
|
||||
template={template}
|
||||
onContentChanged={(k, v) => setContent(
|
||||
prev => ({...prev, [k]: v})
|
||||
)}
|
||||
{isRawMode ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Edit the JSON directly. Make sure it's valid JSON before saving.
|
||||
</p>
|
||||
<Textarea
|
||||
className={`raw-editor h-[70vh] font-mono text-sm ${jsonError ? "border-destructive focus-visible:border-destructive" : ""}`}
|
||||
value={rawContent}
|
||||
onChange={handleRawContentChange}
|
||||
placeholder="Enter JSON content here"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{jsonError && (
|
||||
<p className="font-mono text-xs text-destructive">{jsonError}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TemplatedEditor
|
||||
style={{height: "40vh"}}
|
||||
content={content}
|
||||
template={!markdown?.id ? selectedTemplate : template}
|
||||
onContentChanged={(k, v) => setContent(
|
||||
prev => ({...prev, [k]: v})
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<button
|
||||
className="button is-primary"
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saveMarkdown.isLoading}
|
||||
>
|
||||
{saveMarkdown.isLoading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saveMarkdown.isLoading}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saveMarkdown.isLoading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="column is-half">
|
||||
<h3 className="subtitle is-5">Preview</h3>
|
||||
<MarkdownView content={content} template={template} height='70vh'/>
|
||||
<div>
|
||||
<h3 className="mb-3 font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
|
||||
Preview
|
||||
</h3>
|
||||
<MarkdownView
|
||||
content={content}
|
||||
template={!markdown?.id ? selectedTemplate : template}
|
||||
height='70vh'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,112 +1,173 @@
|
||||
/* Dark-tech prose theme for rendered markdown. */
|
||||
.markdown-preview {
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
color: hsl(var(--foreground) / 0.92);
|
||||
line-height: 1.75;
|
||||
font-size: 0.975rem;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.katex-display {
|
||||
margin: 1em 0;
|
||||
text-align: center;
|
||||
}
|
||||
.katex {
|
||||
font-size: 1.2rem;
|
||||
.markdown-preview > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
background-color: #f4f4f4;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
.markdown-preview p {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
pre {
|
||||
background-color: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
|
||||
.markdown-preview a {
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid hsl(var(--primary) / 0.3);
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.markdown-preview a:hover {
|
||||
color: hsl(var(--primary));
|
||||
border-bottom-color: hsl(var(--primary));
|
||||
text-shadow: 0 0 10px hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.markdown-preview h1,
|
||||
.markdown-preview h2,
|
||||
.markdown-preview h3,
|
||||
.markdown-preview h4,
|
||||
.markdown-preview h5,
|
||||
.markdown-preview h6 {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.3;
|
||||
margin: 2rem 0 0.85rem;
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
.markdown-preview h1 {
|
||||
font-size: 1.9rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
.markdown-preview h1::before,
|
||||
.markdown-preview h2::before {
|
||||
content: "# ";
|
||||
color: hsl(var(--primary) / 0.6);
|
||||
}
|
||||
.markdown-preview h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.markdown-preview h3 {
|
||||
font-size: 1.25rem;
|
||||
color: hsl(var(--foreground) / 0.9);
|
||||
}
|
||||
.markdown-preview h4 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.markdown-preview h5,
|
||||
.markdown-preview h6 {
|
||||
font-size: 0.95rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.markdown-preview ul,
|
||||
.markdown-preview ol {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.markdown-preview ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.markdown-preview ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-preview li {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.markdown-preview li::marker {
|
||||
color: hsl(var(--primary) / 0.7);
|
||||
}
|
||||
|
||||
.markdown-preview h1 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
.markdown-preview blockquote {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-left: 3px solid hsl(var(--secondary));
|
||||
background: hsl(var(--secondary) / 0.08);
|
||||
color: hsl(var(--foreground) / 0.8);
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.markdown-preview h2 {
|
||||
|
||||
font-size: 1.75em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #444;
|
||||
.markdown-preview hr {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.markdown-preview h3 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
.markdown-preview img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
|
||||
.markdown-preview h4 {
|
||||
font-size: 1.25em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #666;
|
||||
/* Inline code */
|
||||
.markdown-preview :not(pre) > code {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.85em;
|
||||
background-color: hsl(var(--muted));
|
||||
color: hsl(var(--primary));
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.markdown-preview h5 {
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
|
||||
.markdown-preview h6 {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #888;
|
||||
/* Fenced code blocks (react-syntax-highlighter wraps in <pre>) */
|
||||
.markdown-preview pre {
|
||||
background-color: hsl(222 40% 4%) !important;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
padding: 1rem !important;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 1rem;
|
||||
box-shadow: inset 0 0 40px -20px hsl(var(--primary) / 0.25);
|
||||
}
|
||||
.markdown-preview pre code {
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.markdown-preview table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #ddd;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.markdown-preview th,
|
||||
.markdown-preview td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
padding: 0.6rem 0.8rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-preview th {
|
||||
background-color: #f4f4f4;
|
||||
font-weight: bold;
|
||||
background-color: hsl(var(--muted));
|
||||
font-family: "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.markdown-preview tr:hover td {
|
||||
background: hsl(var(--accent) / 0.5);
|
||||
}
|
||||
|
||||
/* KaTeX on dark */
|
||||
.katex-display {
|
||||
margin: 1.25em 0;
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.katex {
|
||||
font-size: 1.1rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { okaidia } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
@@ -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 {data: links, isLoading} = useLinks();
|
||||
|
||||
@@ -74,7 +90,7 @@ const MarkdownView = ({ content, template, height="auto" }) => {
|
||||
variables: content
|
||||
}) + "\n" + linkDefinitions}
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex, rehypeRaw]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
|
||||
214
src/components/Markdowns/PatchCards.js
Normal file
214
src/components/Markdowns/PatchCards.js
Normal 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;
|
||||
111
src/components/Markdowns/StandaloneMarkdownPage.js
Normal file
111
src/components/Markdowns/StandaloneMarkdownPage.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./MarkdownContent.css";
|
||||
import MarkdownView from "./MarkdownView";
|
||||
import { useMarkdown } from "../../utils/queries/markdown-queries";
|
||||
import { useMarkdownSetting } from "../../utils/queries/markdown-setting-queries";
|
||||
import { useMarkdownTemplate } from "../../utils/queries/markdown-template-queries";
|
||||
import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-template-setting-queries";
|
||||
import { useTree } from "../../utils/queries/tree-queries";
|
||||
import { getMarkdownIdByPath } from "../../utils/pathUtils";
|
||||
import { parseMarkdownContent } from "../../utils/safe-json";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
const StandaloneMarkdownPage = () => {
|
||||
const location = useLocation();
|
||||
const [indexTitle, setIndexTitle] = useState(null);
|
||||
const [markdownId, setMarkdownId] = useState(null);
|
||||
|
||||
// Extract path from /pg/project/index -> project/index
|
||||
const pathString = location.pathname.replace(/^\/pg\//, '');
|
||||
|
||||
const { data: tree, isLoading: isTreeLoading } = useTree();
|
||||
const { data: markdown, isLoading: isMarkdownLoading, error } = useMarkdown(markdownId);
|
||||
const { data: setting, isFetching: isSettingFetching } = useMarkdownSetting(markdown?.setting_id);
|
||||
const { data: templateSetting, isFetching: isTemplateSettingFetching } = useMarkdownTemplateSetting(setting?.template_setting_id);
|
||||
const { data: template, isFetching: isTemplateFetching } = useMarkdownTemplate(templateSetting?.template_id);
|
||||
|
||||
// Resolve markdown ID from path using tree
|
||||
useEffect(() => {
|
||||
if (tree && pathString) {
|
||||
const resolvedId = getMarkdownIdByPath(tree, pathString);
|
||||
setMarkdownId(resolvedId);
|
||||
}
|
||||
}, [tree, pathString]);
|
||||
|
||||
useEffect(() => {
|
||||
if (markdown && markdown.title === "index" && pathString) {
|
||||
const pathParts = pathString.split('/').filter(part => part.length > 0);
|
||||
|
||||
if (pathParts.length === 0) {
|
||||
// Root index: /pg/ or /pg
|
||||
setIndexTitle("Home");
|
||||
} else {
|
||||
// Directory index: /pg/Projects or /pg/Projects/project1
|
||||
// Use the last directory name as title
|
||||
const directoryName = pathParts[pathParts.length - 1];
|
||||
setIndexTitle(directoryName);
|
||||
}
|
||||
}
|
||||
}, [markdown, pathString]);
|
||||
|
||||
const notReady = isTreeLoading || isMarkdownLoading || isSettingFetching || isTemplateSettingFetching || isTemplateFetching;
|
||||
|
||||
if (notReady) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner label="Loading" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 font-mono text-sm text-destructive">
|
||||
Error: {error.message || "Failed to load content"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!notReady && !markdownId) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-6">
|
||||
<p className="font-mono text-sm text-muted-foreground">
|
||||
Markdown not found for path:{" "}
|
||||
<span className="text-foreground">{pathString}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (markdown?.isMessage) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/10 px-5 py-4">
|
||||
<h4 className="mb-1 font-mono text-base font-semibold text-primary">
|
||||
{markdown.title}
|
||||
</h4>
|
||||
<p className="text-sm text-foreground/80">
|
||||
{markdown.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-10 mx-auto max-w-4xl px-6 py-12">
|
||||
<h1 className="mb-8 border-b border-border pb-4 font-mono text-3xl font-bold tracking-tight text-foreground">
|
||||
{markdown?.title === "index" ? indexTitle : markdown?.title}
|
||||
</h1>
|
||||
{markdown && (
|
||||
<MarkdownView content={parseMarkdownContent(markdown.content)} template={template} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StandaloneMarkdownPage;
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, {useState} from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
|
||||
import { Input, Textarea, Label } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
const SELECT_CLASS =
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
|
||||
|
||||
const FIELD_BOX = "space-y-2 rounded-md border border-border bg-surface/40 p-4";
|
||||
|
||||
const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged }) => {
|
||||
console.log("variable", variable);
|
||||
@@ -9,49 +18,41 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
|
||||
switch (variable.type.base_type) {
|
||||
case "string":
|
||||
return (
|
||||
<div className="box has-background-danger-soft">
|
||||
<label className="label">{__namespace}</label>
|
||||
<div className="control">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onContentChanged(variable.name, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={FIELD_BOX}>
|
||||
<Label>{__namespace}</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onContentChanged(variable.name, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "markdown":
|
||||
return (
|
||||
<div className="box has-background-primary-soft">
|
||||
<label className="label">{__namespace}</label>
|
||||
<div className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={value}
|
||||
onChange={(e) => onContentChanged(variable.name, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={FIELD_BOX}>
|
||||
<Label>{__namespace}</Label>
|
||||
<Textarea
|
||||
className="max-h-[10vh] font-mono text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onContentChanged(variable.name, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "enum":
|
||||
return (
|
||||
<div className="box has-background-info-soft">
|
||||
<label className="label">{__namespace}</label>
|
||||
<div className="control">
|
||||
<div className="select is-fullwidth">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onContentChanged(variable.name, e.target.value)}
|
||||
>
|
||||
{variable.type.definition.enums.map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className={FIELD_BOX}>
|
||||
<Label>{__namespace}</Label>
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
value={value}
|
||||
onChange={(e) => onContentChanged(variable.name, e.target.value)}
|
||||
>
|
||||
{variable.type.definition.enums.map((item) => (
|
||||
<option key={item} value={item}>{item}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -79,11 +80,11 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="box has-background-white-soft">
|
||||
<label className="label">{__namespace}</label>
|
||||
<div className={FIELD_BOX}>
|
||||
<Label>{__namespace}</Label>
|
||||
{cache.map((item, idx) => (
|
||||
<div className="field is-grouped" key={idx}>
|
||||
<div className="control is-expanded">
|
||||
<div className="flex items-start gap-2" key={idx}>
|
||||
<div className="flex-1">
|
||||
<TemplatedEditorComponent
|
||||
variable={{ name: idx, type: variable.type.extend_type }}
|
||||
value={item}
|
||||
@@ -91,35 +92,31 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
|
||||
onContentChanged={(subKey, subVal) => onItemChange(idx, subVal)}
|
||||
/>
|
||||
</div>
|
||||
<div className="control">
|
||||
<button
|
||||
className="button is-danger"
|
||||
type="button"
|
||||
onClick={() => removeItem(idx)}
|
||||
>
|
||||
DELETE
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => removeItem(idx)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<button
|
||||
className="button is-warning"
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
>
|
||||
ADD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "template": {
|
||||
const { data: _template, isFetching: loading } = useMarkdownTemplate(variable.type.definition.template.id);
|
||||
if (loading) return <p>Loading...</p>;
|
||||
if (loading) return <Spinner label="Loading template" />;
|
||||
const _parameters = _template.parameters;
|
||||
|
||||
const handleSubChange = (key, val) => {
|
||||
@@ -128,10 +125,10 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="box has-background-grey-light">
|
||||
<label className="label">{__namespace}</label>
|
||||
<div className={FIELD_BOX}>
|
||||
<Label>{__namespace}</Label>
|
||||
{_parameters.map((param, i) => (
|
||||
<div className="field" key={i}>
|
||||
<div key={i}>
|
||||
<TemplatedEditorComponent
|
||||
variable={param}
|
||||
value={(value || {})[param.name]}
|
||||
@@ -152,7 +149,7 @@ const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged
|
||||
return <>{renderField()}</>;
|
||||
};
|
||||
|
||||
const TemplatedEditor = ({ content, template, onContentChanged }) => {
|
||||
const TemplatedEditor = ({ content, template, onContentChanged, style }) => {
|
||||
const tpl = template || {
|
||||
parameters: [{ name: "markdown", type: { base_type: "markdown", definition: {} } }],
|
||||
layout: "<markdown/>",
|
||||
@@ -160,18 +157,23 @@ const TemplatedEditor = ({ content, template, onContentChanged }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="box">
|
||||
{tpl.parameters.map((variable, idx) => (
|
||||
<TemplatedEditorComponent
|
||||
key={idx}
|
||||
variable={variable}
|
||||
value={content[variable.name]}
|
||||
namespace={tpl.title}
|
||||
onContentChanged={onContentChanged}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className="flex flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||
style={style}
|
||||
>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{tpl.parameters.map((variable, idx) => (
|
||||
<TemplatedEditorComponent
|
||||
key={idx}
|
||||
variable={variable}
|
||||
value={content[variable.name]}
|
||||
namespace={tpl.title}
|
||||
onContentChanged={onContentChanged}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplatedEditor;
|
||||
export default TemplatedEditor;
|
||||
|
||||
168
src/components/Modals/ApiKeyCreationModal.js
Normal file
168
src/components/Modals/ApiKeyCreationModal.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Trash2, Copy, KeyRound } from 'lucide-react';
|
||||
import { useCreateApiKey } from '../../utils/queries/apikey-queries';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input, Label } from '../ui/input';
|
||||
|
||||
const AVAILABLE_ROLES = ['guest', 'creator', 'admin'];
|
||||
|
||||
const SELECT_CLASS =
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
const ApiKeyCreationModal = ({ isOpen, onClose }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [roles, setRoles] = useState(['guest']);
|
||||
const [generatedKey, setGeneratedKey] = useState(null);
|
||||
const createApiKeyMutation = useCreateApiKey();
|
||||
|
||||
const handleAddRole = () => {
|
||||
const availableRoles = AVAILABLE_ROLES.filter(role => !roles.includes(role));
|
||||
if (availableRoles.length > 0) {
|
||||
setRoles([...roles, availableRoles[0]]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = (index, value) => {
|
||||
if (roles.includes(value) && roles.findIndex(r => r === value) !== index) {
|
||||
return;
|
||||
}
|
||||
const newRoles = [...roles];
|
||||
newRoles[index] = value;
|
||||
setRoles(newRoles);
|
||||
};
|
||||
|
||||
const handleRemoveRole = (index) => {
|
||||
const newRoles = roles.filter((_, i) => i !== index);
|
||||
setRoles(newRoles);
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!name.trim()) {
|
||||
alert('API key name is required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await createApiKeyMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
roles: roles
|
||||
});
|
||||
setGeneratedKey(result);
|
||||
} catch (error) {
|
||||
console.error('failed to create api key', error);
|
||||
alert('failed to create api key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(generatedKey)
|
||||
.then(() => alert('API key copied to clipboard'))
|
||||
.catch(err => console.error('failed to copy api key:', err));
|
||||
};
|
||||
|
||||
const getRemainingRoles = (currentIndex) => {
|
||||
return AVAILABLE_ROLES.filter(role =>
|
||||
!roles.find((r, i) => r === role && i !== currentIndex)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!generatedKey ? (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key-name">Name</Label>
|
||||
<Input
|
||||
id="api-key-name"
|
||||
type="text"
|
||||
placeholder="API key name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Roles</Label>
|
||||
<div className="space-y-2">
|
||||
{roles.map((role, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
value={role}
|
||||
onChange={(e) => handleRoleChange(index, e.target.value)}
|
||||
>
|
||||
{getRemainingRoles(index).map(availableRole => (
|
||||
<option key={availableRole} value={availableRole}>
|
||||
{availableRole}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveRole(index)}
|
||||
disabled={roles.length === 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddRole}
|
||||
disabled={roles.length === AVAILABLE_ROLES.length}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Add Role
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-secondary/40 bg-secondary/10 px-4 py-3 font-mono text-sm text-secondary">
|
||||
Please copy your API key immediately! It will only be displayed once!
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="generated-api-key">Your API Key</Label>
|
||||
<Input
|
||||
id="generated-api-key"
|
||||
className="font-mono"
|
||||
type="text"
|
||||
value={generatedKey.key}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4" /> Copy
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{!generatedKey && (
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={createApiKeyMutation.isLoading || !name.trim()}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" /> Generate
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyCreationModal;
|
||||
67
src/components/Modals/ApiKeyRevokeModal.js
Normal file
67
src/components/Modals/ApiKeyRevokeModal.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useRevokeApiKey } from '../../utils/queries/apikey-queries';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input, Label } from '../ui/input';
|
||||
|
||||
const ApiKeyRevokeModal = ({ isOpen, onClose }) => {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const revokeApiKeyMutation = useRevokeApiKey();
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
alert('Please enter an API key');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await revokeApiKeyMutation.mutateAsync(apiKey);
|
||||
alert('API key revoked successfully');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke API key:', error);
|
||||
alert('Failed to revoke API key');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke API Key</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="revoke-api-key">API Key</Label>
|
||||
<Input
|
||||
id="revoke-api-key"
|
||||
type="text"
|
||||
placeholder="Enter API key to revoke"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleRevoke}
|
||||
disabled={revokeApiKeyMutation.isLoading}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" /> Revoke
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyRevokeModal;
|
||||
42
src/components/Modals/JsonSchemaModal.js
Normal file
42
src/components/Modals/JsonSchemaModal.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Copy } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Textarea } from '../ui/input';
|
||||
|
||||
const JsonSchemaModal = ({ isActive, onClose, schema }) => {
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(schema, null, 2));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isActive} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>JSON Schema</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
className="h-[50vh] font-mono text-xs"
|
||||
value={JSON.stringify(schema, null, 2)}
|
||||
readOnly
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4" /> Copy
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonSchemaModal;
|
||||
@@ -1,8 +1,18 @@
|
||||
import {useCreateMarkdownSetting, useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
|
||||
import {useSaveMarkdown} from "../../utils/queries/markdown-queries";
|
||||
import React, {useState} from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import MarkdownTemplateSettingPanel from "../Settings/MarkdownSettings/MarkdownTemplateSettingPanel";
|
||||
import MarkdownPermissionSettingPanel from "../Settings/MarkdownSettings/MarkdownPermissionSettingPanel";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||
import { Button } from "../ui/button";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
const MarkdownSettingModal = ({isOpen, markdown, onClose}) => {
|
||||
const {data: markdownSetting, isFetching: markdownSettingIsFetching} = useMarkdownSetting(markdown?.setting_id || 0);
|
||||
@@ -24,62 +34,45 @@ const MarkdownSettingModal = ({isOpen, markdown, onClose}) => {
|
||||
});
|
||||
};
|
||||
|
||||
if(markdownSettingIsFetching)
|
||||
return(<p>Loading...</p>);
|
||||
|
||||
return (
|
||||
<div className={`modal ${isOpen ? "is-active" : ""}`}>
|
||||
<div className="modal-background" onClick={onClose} />
|
||||
<div className="modal-card" style={{width: "60vw"}}>
|
||||
<header className="modal-card-head">
|
||||
<p className="modal-card-title">Markdown Settings</p>
|
||||
<button
|
||||
className="delete"
|
||||
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Markdown Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
{markdownSettingIsFetching ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner label="Loading settings" />
|
||||
</div>
|
||||
) : markdownSetting ? (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="template">Template</TabsTrigger>
|
||||
<TabsTrigger value="permission">Permission</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="template">
|
||||
<MarkdownTemplateSettingPanel
|
||||
markdownSetting={markdownSetting}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="permission">
|
||||
<MarkdownPermissionSettingPanel
|
||||
markdownSetting={markdownSetting}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
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>
|
||||
{activeTab === "template" && (
|
||||
<MarkdownTemplateSettingPanel
|
||||
markdownSetting={markdownSetting}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "permission" && (
|
||||
<MarkdownPermissionSettingPanel
|
||||
markdownSetting={markdownSetting}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="modal-card-body">
|
||||
<button
|
||||
className="button is-primary"
|
||||
type="button"
|
||||
onClick={handleCreateMarkdownSetting}
|
||||
>
|
||||
Create Markdown Setting
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
onClick={handleCreateMarkdownSetting}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Create Markdown Setting
|
||||
</Button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
@@ -2,6 +2,17 @@ import {useUpdatePath} from "../../utils/queries/path-queries";
|
||||
import {useCreatePathSetting, usePathSetting} from "../../utils/queries/path-setting-queries";
|
||||
import WebhookSettingPanel from "../Settings/PathSettings/WebhookSettingPanel";
|
||||
import React, {useState} from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||
import { Button } from "../ui/button";
|
||||
import { Spinner } from "../ui/misc";
|
||||
|
||||
const PathSettingModal = ({ isOpen, path, onClose }) => {
|
||||
const settingId = path?.setting_id || 0;
|
||||
const {data: pathSetting, isLoading: isPathSettingLoading} = usePathSetting(settingId);
|
||||
@@ -17,59 +28,42 @@ const PathSettingModal = ({ isOpen, path, onClose }) => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
if(settingId && isPathSettingLoading)
|
||||
return (<p>Loading...</p>);
|
||||
|
||||
return (
|
||||
<div className={`modal ${isOpen ? "is-active" : ""}`}>
|
||||
<div className="modal-background" onClick={onClose} />
|
||||
<div className="modal-card" style={{width: "60vw"}}>
|
||||
<header className="modal-card-head">
|
||||
<p className="modal-card-title">Path Settings</p>
|
||||
<button
|
||||
<Dialog open={isOpen} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Path Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
{settingId && isPathSettingLoading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner label="Loading settings" />
|
||||
</div>
|
||||
) : !pathSetting ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="delete"
|
||||
aria-label="close"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</header>
|
||||
{!pathSetting ? (
|
||||
<section className="modal-card-body">
|
||||
<button
|
||||
type="button"
|
||||
className="button is-primary"
|
||||
onClick={handleCreatePathSetting}
|
||||
>
|
||||
Create Path Setting
|
||||
</button>
|
||||
</section>
|
||||
onClick={handleCreatePathSetting}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Create Path Setting
|
||||
</Button>
|
||||
) : (
|
||||
<section className="modal-card-body">
|
||||
<div className="tabs">
|
||||
<ul>
|
||||
<li className={activeTab === "webhook" ? "is-active" : ""}>
|
||||
<a onClick={() => setActiveTab("webhook")}>Webhook</a>
|
||||
</li>
|
||||
<li className={activeTab === "template" ? "is-active" : ""}>
|
||||
<a onClick={() => setActiveTab("template")}>Template</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{activeTab === "webhook" && (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
||||
<TabsTrigger value="template">Template</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="webhook">
|
||||
<WebhookSettingPanel
|
||||
pathSetting={pathSetting}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "template" && (
|
||||
</TabsContent>
|
||||
<TabsContent value="template">
|
||||
<div></div>
|
||||
)}
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
@@ -1,41 +1,67 @@
|
||||
import React, {useContext, useState} from "react";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AuthContext } from "../../AuthProvider";
|
||||
import "bulma/css/bulma.min.css";
|
||||
import {useConfig} from "../../ConfigProvider";
|
||||
import "./MainNavigation.css";
|
||||
import { useConfig } from "../../ConfigProvider";
|
||||
import {
|
||||
Download,
|
||||
Upload,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
LogOut,
|
||||
LogIn,
|
||||
ChevronDown,
|
||||
Mail,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import ApiKeyCreationModal from "../Modals/ApiKeyCreationModal";
|
||||
import ApiKeyRevokeModal from "../Modals/ApiKeyRevokeModal";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
const MainNavigation = () => {
|
||||
const { user, login, logout } = useContext(AuthContext);
|
||||
const config = useConfig();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
if (config===undefined) {
|
||||
return <div>Loading ...</div>;
|
||||
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
|
||||
const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
|
||||
|
||||
if (config === undefined) {
|
||||
return (
|
||||
<header className="flex h-14 items-center border-b border-border px-5 text-sm text-muted-foreground">
|
||||
Loading …
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLoadBackup = async () => {
|
||||
try{
|
||||
try {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept=".zip";
|
||||
input.accept = ".zip";
|
||||
input.onchange = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if(!file)
|
||||
return;
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try{
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${config.BACKEND_HOST}/api/backup/load`, {
|
||||
`${config.BACKEND_HOST}/api/backup/load`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
|
||||
},
|
||||
body: formData
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
if(response.ok){
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
await response.json();
|
||||
alert("Backup loaded");
|
||||
} else {
|
||||
const error = await response.json();
|
||||
@@ -51,25 +77,28 @@ const MainNavigation = () => {
|
||||
console.error(error);
|
||||
alert(`Unexpected error`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetBackup = async () => {
|
||||
try{
|
||||
const response = await fetch(
|
||||
`${config.BACKEND_HOST}/api/backup/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
|
||||
}
|
||||
}
|
||||
);
|
||||
if(response.ok){
|
||||
try {
|
||||
const response = await fetch(`${config.BACKEND_HOST}/api/backup/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const contentDisposition = response.headers.get("Content-Disposition");
|
||||
const contentDisposition = response.headers.get(
|
||||
"Content-Disposition"
|
||||
);
|
||||
let filename = "backup.zip";
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
const match = contentDisposition.match(
|
||||
/filename="?([^"]+)"?/
|
||||
);
|
||||
if (match && match[1]) {
|
||||
filename = match[1];
|
||||
}
|
||||
@@ -78,113 +107,119 @@ const MainNavigation = () => {
|
||||
a.download = filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}else{
|
||||
} else {
|
||||
alert("Failed to get backup");
|
||||
}
|
||||
} catch(err){
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
alert("An error occurred while retrieving backup");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||
<div className="navbar-brand">
|
||||
<Link className="navbar-item" to="/">
|
||||
<img src="/icons/logo.png" alt="Logo" style={{ height: "40px", marginRight: "10px" }} />
|
||||
Home
|
||||
</Link>
|
||||
<a
|
||||
role="button"
|
||||
className="navbar-burger"
|
||||
aria-label="menu"
|
||||
aria-expanded="false"
|
||||
data-target="navbarBasicExample"
|
||||
onClick={(e) => {
|
||||
e.currentTarget.classList.toggle("is-active");
|
||||
document
|
||||
.getElementById("navbarBasicExample")
|
||||
.classList.toggle("is-active");
|
||||
}}
|
||||
<>
|
||||
<header className="glass relative z-20 flex h-14 shrink-0 items-center gap-1 border-b border-border px-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="group flex items-center gap-2.5 pr-3"
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
<img
|
||||
src="/icons/logo.png"
|
||||
alt="Logo"
|
||||
className="h-7 w-7 rounded"
|
||||
/>
|
||||
<span className="font-mono text-sm font-semibold tracking-tight">
|
||||
<span className="text-foreground">HANGMAN</span>
|
||||
<span className="neon-text">//LAB</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div id="navbarBasicExample" className="navbar-menu">
|
||||
<div className="navbar-start">
|
||||
<nav className="ml-2 hidden items-center gap-1 sm:flex">
|
||||
<a
|
||||
href="https://mail.hangman-lab.top"
|
||||
className="navbar-item"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
MailBox
|
||||
<Mail className="h-3.5 w-3.5" /> Mail
|
||||
</a>
|
||||
<a
|
||||
href="https://git.hangman-lab.top"
|
||||
className="navbar-item"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Git
|
||||
<GitBranch className="h-3.5 w-3.5" /> Git
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="navbar-end">
|
||||
<div className="ml-auto flex items-center">
|
||||
{user && user.profile ? (
|
||||
<div className="navbar-item has-dropdown is-hoverable">
|
||||
<div className="buttons">
|
||||
<span
|
||||
className="button is-primary is-light"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<span className="grid h-5 w-5 place-items-center rounded-full bg-primary/15 font-mono text-[10px] font-bold text-primary">
|
||||
{(user.profile.name || "?")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
<span className="max-w-[10rem] truncate">
|
||||
{user.profile.name}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
{user.profile.name}
|
||||
</span>
|
||||
<div className={`navbar-dropdown ${isDropdownOpen ? "is-active" : ""}`}>
|
||||
<button
|
||||
className="button is-primary dropdown-option"
|
||||
onClick={handleGetBackup}
|
||||
type="button"
|
||||
>
|
||||
Get Backup
|
||||
</button>
|
||||
<button
|
||||
className="button is-primary dropdown-option"
|
||||
onClick={handleLoadBackup}
|
||||
type="button"
|
||||
>
|
||||
Load Backup
|
||||
</button>
|
||||
<button
|
||||
className="button is-danger dropdown-option"
|
||||
onClick={logout}
|
||||
type="button"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleGetBackup}>
|
||||
<Download /> Get Backup
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLoadBackup}>
|
||||
<Upload /> Load Backup
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsApiKeyModalOpen(true)}
|
||||
>
|
||||
<KeyRound /> Create API Key
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIsRevokeModalOpen(true)}
|
||||
>
|
||||
<KeySquare /> Revoke API Key
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={logout}
|
||||
className="text-destructive focus:text-destructive [&_svg]:text-destructive"
|
||||
>
|
||||
<LogOut /> Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="navbar-item">
|
||||
<button
|
||||
className="button is-primary"
|
||||
onClick={login}
|
||||
type="button"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
<Button size="sm" onClick={login} className="gap-2">
|
||||
<LogIn className="h-4 w-4" /> Login
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<ApiKeyCreationModal
|
||||
isOpen={isApiKeyModalOpen}
|
||||
onClose={() => setIsApiKeyModalOpen(false)}
|
||||
/>
|
||||
<ApiKeyRevokeModal
|
||||
isOpen={isRevokeModalOpen}
|
||||
onClose={() => setIsRevokeModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainNavigation;
|
||||
export default MainNavigation;
|
||||
|
||||
@@ -1,47 +1,82 @@
|
||||
import {Link} from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
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";
|
||||
const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
|
||||
import { useDeleteMarkdown } from "../../utils/queries/markdown-queries";
|
||||
import { useDeleteMarkdownSetting } from "../../utils/queries/markdown-setting-queries";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const iconBtn =
|
||||
"grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary";
|
||||
|
||||
const MarkdownNode = ({ markdown, handleMoveMarkdown }) => {
|
||||
const [isMarkdownSettingModalOpen, setIsMarkdownSettingModalOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const deleteMarkdown = useDeleteMarkdown();
|
||||
const deleteMarkdownSetting = useDeleteMarkdownSetting();
|
||||
|
||||
const handleDeleteMarkdown = async () => {
|
||||
if (!window.confirm(`delete markdown "${markdown.title}" ? this action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteMarkdown.mutateAsync(markdown.id);
|
||||
if (window.location.pathname === `/markdown/${markdown.id}`) {
|
||||
navigate('/');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('failed: ' + (error.message || 'unknown error'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={markdown.id}>
|
||||
<div className="is-clickable field has-addons">
|
||||
<span className="markdown-name has-text-weight-bold control">
|
||||
<Link to={`/markdown/${markdown.id}`} className="is-link markdown-node">
|
||||
{markdown.title}
|
||||
</Link>
|
||||
</span>
|
||||
<li>
|
||||
<div className="group flex items-center gap-1 rounded-md px-1 py-1 pl-1.5 transition-colors hover:bg-accent/60">
|
||||
<FileText className="ml-5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Link
|
||||
to={`/markdown/${markdown.id}`}
|
||||
className="min-w-0 flex-1 truncate text-sm text-foreground/90 transition-colors hover:text-primary"
|
||||
>
|
||||
{markdown.title}
|
||||
</Link>
|
||||
<PermissionGuard rolesRequired={['admin']}>
|
||||
<p className="control">
|
||||
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
className="button is-small is-success"
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Settings"
|
||||
onClick={() => setIsMarkdownSettingModalOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-cog"/>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
<div
|
||||
className="control is-flex is-flex-direction-column is-align-items-center"
|
||||
style={{marginLeft: "0.5rem"}}
|
||||
>
|
||||
<button
|
||||
className="button is-small mb-1 move-forward"
|
||||
style={{height: "1rem", padding: "0.25rem"}}
|
||||
onClick={() => handleMoveMarkdown(markdown, "forward")}
|
||||
type="button"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="button is-small mb-1 move-backward"
|
||||
style={{height: "1rem", padding: "0.25rem"}}
|
||||
onClick={() => handleMoveMarkdown(markdown, "backward")}
|
||||
type="button"
|
||||
className={cn(iconBtn, "hover:text-destructive")}
|
||||
title="Delete"
|
||||
onClick={handleDeleteMarkdown}
|
||||
disabled={deleteMarkdown.isLoading || deleteMarkdownSetting.isLoading}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
|
||||
onClick={() => handleMoveMarkdown(markdown, "forward")}
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
|
||||
onClick={() => handleMoveMarkdown(markdown, "backward")}
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownSettingModal
|
||||
isOpen={isMarkdownSettingModalOpen}
|
||||
@@ -50,9 +85,8 @@ const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
|
||||
/>
|
||||
</PermissionGuard>
|
||||
</div>
|
||||
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default MarkdownNode;
|
||||
export default MarkdownNode;
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import React, {useState} from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { toggleNodeExpansion } from '../../store/navigationSlice';
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Settings2,
|
||||
Pencil,
|
||||
Check,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import PermissionGuard from "../PermissionGuard";
|
||||
import "./PathNode.css";
|
||||
import {useDeletePath, useMovePath, useUpdatePath} from "../../utils/queries/path-queries";
|
||||
import {useIndexMarkdown, useMoveMarkdown} from "../../utils/queries/markdown-queries";
|
||||
import { useDeletePath, useMovePath, useUpdatePath } from "../../utils/queries/path-queries";
|
||||
import { useIndexMarkdown, useMoveMarkdown } from "../../utils/queries/markdown-queries";
|
||||
import MarkdownNode from "./MarkdownNode";
|
||||
import PathSettingModal from "../Modals/PathSettingModal";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const iconBtn =
|
||||
"grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary";
|
||||
|
||||
const PathNode = ({ path, isRoot = false }) => {
|
||||
const [isPathSettingModalOpen, setIsPathSettingModalOpen] = useState(false);
|
||||
@@ -20,76 +34,57 @@ const PathNode = ({ path, isRoot = false }) => {
|
||||
|
||||
const deletePath = useDeletePath();
|
||||
const updatePath = useUpdatePath();
|
||||
|
||||
const {data: indexMarkdown} = useIndexMarkdown(path.id);
|
||||
|
||||
const { data: indexMarkdown } = useIndexMarkdown(path.id);
|
||||
const movePath = useMovePath();
|
||||
const moveMarkdown = useMoveMarkdown();
|
||||
|
||||
|
||||
const expand = () => {
|
||||
if (!isExpanded) {
|
||||
dispatch(toggleNodeExpansion(path.id));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = () => {
|
||||
dispatch(toggleNodeExpansion(path.id));
|
||||
if (!isExpanded) dispatch(toggleNodeExpansion(path.id));
|
||||
};
|
||||
const toggleExpand = () => dispatch(toggleNodeExpansion(path.id));
|
||||
|
||||
const handleSave = () => {
|
||||
updatePath.mutate({id: path.id, data: {name: newName}}, {
|
||||
updatePath.mutate({ id: path.id, data: { name: newName } }, {
|
||||
onSuccess: () => setIsEditing(false),
|
||||
onError: err => alert("failed to update this path"),
|
||||
})
|
||||
onError: () => alert("failed to update this path"),
|
||||
});
|
||||
};
|
||||
const handleDelete = () => {
|
||||
if(window.confirm("Are you sure?")) {
|
||||
if (window.confirm("Are you sure?")) {
|
||||
deletePath.mutate(path.id, {
|
||||
onError: err => alert("failed to delete this path"),
|
||||
})
|
||||
onError: () => alert("failed to delete this path"),
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
const handleEdit = () => setIsEditing(true);
|
||||
|
||||
const handleMovePath = (pth, direction) => {
|
||||
movePath.mutate({path: pth, direction: direction}, {
|
||||
movePath.mutate({ path: pth, direction }, {
|
||||
onError: () => alert("failed to move this path"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveMarkdown = (md, direction) => {
|
||||
moveMarkdown.mutate({markdown: md, direction: direction}, {
|
||||
moveMarkdown.mutate({ markdown: md, direction }, {
|
||||
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
|
||||
? 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
|
||||
? markdowns.filter(md => md.title !== "index").sort((a, b) => a.order.localeCompare(b.order))
|
||||
: [];
|
||||
|
||||
|
||||
if(isRoot)
|
||||
if (isRoot)
|
||||
return (
|
||||
<ul className="menu-list">
|
||||
{sortedPaths.map((path) => (
|
||||
<PathNode
|
||||
key={path.id}
|
||||
path={path}
|
||||
isRoot={false}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<ul className="space-y-0.5">
|
||||
{sortedPaths.map((p) => (
|
||||
<PathNode key={p.id} path={p} isRoot={false} />
|
||||
))}
|
||||
{sortedMarkdowns.filter(md => md.title !== "index").map((markdown) => (
|
||||
{sortedMarkdowns.map((markdown) => (
|
||||
<MarkdownNode
|
||||
markdown={markdown}
|
||||
handleMoveMarkdown={handleMoveMarkdown}
|
||||
@@ -98,106 +93,110 @@ const PathNode = ({ path, isRoot = false }) => {
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
return (
|
||||
<li key={path.id}>
|
||||
<div className="path-node-header field has-addons">
|
||||
<span className="control has-text-weight-bold path-toggle" onClick={isRoot ? undefined : toggleExpand}>
|
||||
{isExpanded ? "-" : "+"}
|
||||
</span>
|
||||
{isEditing ? (
|
||||
<div className="control has-icons-left">
|
||||
<input
|
||||
className="input is-small path-edit-input"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className="group flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-accent/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpand}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
className="flex shrink-0 items-center gap-1 rounded text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 text-secondary" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-secondary/70" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="h-6 min-w-0 flex-1 rounded border border-input bg-background/60 px-1.5 text-xs text-foreground focus:border-primary/60 focus:outline-none"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
/>
|
||||
) : indexMarkdown ? (
|
||||
// Clicking the name navigates to the folder's index page
|
||||
// AND expands the subtree (expanded state is global, so
|
||||
// the children stay visible after navigation).
|
||||
<Link
|
||||
to={`/markdown/${indexMarkdown.id}`}
|
||||
onClick={expand}
|
||||
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
{path.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="path-name has-text-weight-bold control"
|
||||
onClick={isRoot ? undefined : expand}
|
||||
onClick={toggleExpand}
|
||||
className="min-w-0 flex-1 cursor-pointer truncate text-sm font-medium text-foreground"
|
||||
>
|
||||
{
|
||||
indexMarkdown ? (
|
||||
<Link to={`/markdown/${indexMarkdown.id}`} className="is-link index-path-node">
|
||||
{path.name}
|
||||
</Link>
|
||||
) : (
|
||||
<a className="is-link path-node">{path.name}</a>
|
||||
)
|
||||
}
|
||||
|
||||
{path.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<PermissionGuard rolesRequired={["admin"]}>
|
||||
<div className="field has-addons actions control is-justify-content-flex-end">
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-small is-success"
|
||||
onClick={() => {
|
||||
setIsPathSettingModalOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-cog"/>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
{isEditing ? (
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-small is-success"
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-check"></i>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-small is-info"
|
||||
onClick={handleEdit}
|
||||
type="button"
|
||||
>
|
||||
<span className="icon">
|
||||
<i className="fas fa-pen"></i>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-danger is-small"
|
||||
onClick={handleDelete}
|
||||
type="button">
|
||||
<span className="icon">
|
||||
<i className="fas fa-trash"></i>
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
<div
|
||||
className="control is-flex is-flex-direction-column is-align-items-center"
|
||||
style={{marginLeft: "0.5rem"}}
|
||||
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Settings"
|
||||
onClick={() => setIsPathSettingModalOpen(true)}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isEditing ? (
|
||||
<button
|
||||
className="button is-small mb-1 move-forward"
|
||||
style={{height: "1rem", padding: "0.25rem"}}
|
||||
onClick={() => handleMovePath(path, "forward")}
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Save"
|
||||
onClick={handleSave}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
title="Rename"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(iconBtn, "hover:text-destructive")}
|
||||
title="Delete"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
|
||||
onClick={() => handleMovePath(path, "forward")}
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
className="button is-small mb-1 move-backward"
|
||||
style={{height: "1rem", padding: "0.25rem"}}
|
||||
onClick={() => handleMovePath(path, "backward")}
|
||||
type="button"
|
||||
className="grid h-3 w-5 place-items-center text-muted-foreground hover:text-primary"
|
||||
onClick={() => handleMovePath(path, "backward")}
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,15 +209,11 @@ const PathNode = ({ path, isRoot = false }) => {
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<ul>
|
||||
<ul className="ml-3 space-y-0.5 border-l border-border/60 pl-2">
|
||||
{sortedPaths.map((child) => (
|
||||
<PathNode
|
||||
key={child.id}
|
||||
path={child}
|
||||
/>
|
||||
<PathNode key={child.id} path={child} />
|
||||
))}
|
||||
|
||||
{sortedMarkdowns.filter(md => md.title !== "index").map((markdown) => (
|
||||
{sortedMarkdowns.map((markdown) => (
|
||||
<MarkdownNode
|
||||
markdown={markdown}
|
||||
handleMoveMarkdown={handleMoveMarkdown}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { setSelectedTab } from '../../store/navigationSlice';
|
||||
import "./SideNavigation.css";
|
||||
import { FolderTree, LayoutTemplate } from "lucide-react";
|
||||
import TreeTab from "./SideTabs/TreeTab";
|
||||
import TemplateTab from "./SideTabs/TemplateTab";
|
||||
import { AuthContext } from "../../AuthProvider";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const SideNavigation = () => {
|
||||
const { roles } = useContext(AuthContext);
|
||||
@@ -12,8 +14,8 @@ const SideNavigation = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const allTabs = [
|
||||
{ id: "tree", label: "Tree", component: <TreeTab /> },
|
||||
{ id: "templates", label: "Templates", component: <TemplateTab /> },
|
||||
{ id: "tree", label: "Tree", icon: FolderTree, component: <TreeTab /> },
|
||||
{ id: "templates", label: "Templates", icon: LayoutTemplate, component: <TemplateTab /> },
|
||||
];
|
||||
|
||||
const visibleTabs = roles.includes("admin")
|
||||
@@ -26,28 +28,35 @@ const SideNavigation = () => {
|
||||
}
|
||||
}, [visibleTabs, selectedTab, dispatch]);
|
||||
|
||||
|
||||
const current = visibleTabs.find(t => t.id === selectedTab);
|
||||
|
||||
return (
|
||||
<aside className="side-nav">
|
||||
<div className="tabs is-small">
|
||||
<ul>
|
||||
{visibleTabs.map(tab => (
|
||||
<li
|
||||
<aside className="flex w-72 shrink-0 flex-col border-r border-border bg-surface/40">
|
||||
<div className="flex items-center gap-1 border-b border-border p-2">
|
||||
{visibleTabs.map(tab => {
|
||||
const Icon = tab.icon;
|
||||
const active = tab.id === selectedTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
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))}>
|
||||
{tab.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="tab-content">
|
||||
{current?.component}
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3">{current?.component}</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import React, { useState } from "react";
|
||||
import { useMarkdownTemplates } from "../../../utils/queries/markdown-template-queries";
|
||||
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 { Input } from "../../ui/input";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Spinner } from "../../ui/misc";
|
||||
|
||||
const TemplateTab = () => {
|
||||
const { data: templates, isLoading, error } = useMarkdownTemplates();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selectedSchema, setSelectedSchema] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const filteredTemplates = templates?.filter(template =>
|
||||
@@ -16,46 +22,162 @@ const TemplateTab = () => {
|
||||
navigate(`/template/edit/${templateId}`);
|
||||
};
|
||||
|
||||
if (isLoading) return <p>Loading...</p>;
|
||||
if (error) return <p>Error loading templates</p>;
|
||||
const generateJsonSchema = (template) => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
$defs: {}
|
||||
};
|
||||
|
||||
const generateTypeSchema = (param, defName) => {
|
||||
switch (param.type.base_type) {
|
||||
case "string":
|
||||
return {
|
||||
type: "string"
|
||||
};
|
||||
case "markdown":
|
||||
return {
|
||||
type: "string",
|
||||
description: "Markdown content"
|
||||
};
|
||||
case "enum":
|
||||
return {
|
||||
type: "string",
|
||||
enum: param.type.definition.enums
|
||||
};
|
||||
case "list":
|
||||
if (param.type.extend_type.base_type === "string") {
|
||||
return {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string"
|
||||
}
|
||||
};
|
||||
} else if (param.type.extend_type.base_type === "list" ||
|
||||
param.type.extend_type.base_type === "template") {
|
||||
const itemsDefName = `${defName}_items`;
|
||||
schema.$defs[itemsDefName] = generateTypeSchema(
|
||||
{ type: param.type.extend_type },
|
||||
itemsDefName
|
||||
);
|
||||
return {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: `#/$defs/${itemsDefName}`
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "array",
|
||||
items: generateTypeSchema(
|
||||
{ type: param.type.extend_type },
|
||||
`${defName}_items`
|
||||
)
|
||||
};
|
||||
}
|
||||
case "template":
|
||||
const nestedTemplate = templates.find(t => t.id === param.type.definition.template.id);
|
||||
if (nestedTemplate) {
|
||||
const nestedSchema = {
|
||||
type: "object",
|
||||
properties: {}
|
||||
};
|
||||
nestedTemplate.parameters.forEach(nestedParam => {
|
||||
nestedSchema.properties[nestedParam.name] = generateTypeSchema(
|
||||
nestedParam,
|
||||
`${defName}_${nestedParam.name}`
|
||||
);
|
||||
});
|
||||
return nestedSchema;
|
||||
} else {
|
||||
return {
|
||||
type: "object",
|
||||
properties: {}
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
type: "object",
|
||||
properties: {}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
template.parameters.forEach(param => {
|
||||
const defName = `param_${param.name}`;
|
||||
schema.properties[param.name] = generateTypeSchema(param, defName);
|
||||
});
|
||||
|
||||
if (Object.keys(schema.$defs).length === 0) {
|
||||
delete schema.$defs;
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
if (isLoading) return <Spinner label="Loading templates" />;
|
||||
if (error)
|
||||
return (
|
||||
<p className="font-mono text-xs text-destructive">
|
||||
Error loading templates
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="menu">
|
||||
<div className="control is-expanded">
|
||||
<input
|
||||
className="input is-small"
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 text-xs"
|
||||
type="text"
|
||||
placeholder="Search templates..."
|
||||
placeholder="Search templates…"
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<PermissionGuard rolesRequired={["admin", "creator"]}>
|
||||
<a
|
||||
href="/template/create"
|
||||
className="button is-primary is-small is-fullwidth"
|
||||
style={{ marginBottom: "10px" }}
|
||||
>
|
||||
Create New Template
|
||||
</a>
|
||||
<Button asChild size="sm" variant="outline" className="w-full">
|
||||
<Link to="/template/create">
|
||||
<LayoutTemplate className="h-4 w-4" /> New Template
|
||||
</Link>
|
||||
</Button>
|
||||
</PermissionGuard>
|
||||
|
||||
{!filteredTemplates || filteredTemplates.length === 0 ? (
|
||||
<p>No templates found</p>
|
||||
) : (
|
||||
<div className="template-list">
|
||||
{filteredTemplates.map(template => (
|
||||
<button
|
||||
key={template.id}
|
||||
className="button is-light is-fullwidth template-button"
|
||||
onClick={() => handleTemplateClick(template.id)}
|
||||
style={{ marginBottom: "5px", textAlign: "left", justifyContent: "flex-start" }}
|
||||
>
|
||||
<ul className="space-y-0.5">
|
||||
{filteredTemplates?.map((template) => (
|
||||
<li
|
||||
key={template.id}
|
||||
className="group flex items-center gap-1 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-secondary/80" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground/90">
|
||||
{template.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
|
||||
title="Edit"
|
||||
onClick={() => handleTemplateClick(template.id)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
|
||||
title="JSON schema"
|
||||
onClick={() => setSelectedSchema(generateJsonSchema(template))}
|
||||
>
|
||||
<Braces className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<JsonSchemaModal
|
||||
isActive={selectedSchema !== null}
|
||||
onClose={() => setSelectedSchema(null)}
|
||||
schema={selectedSchema}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,42 +1,40 @@
|
||||
import PermissionGuard from "../../PermissionGuard";
|
||||
import PathNode from "../PathNode";
|
||||
import React from "react";
|
||||
import {useTree} from "../../../utils/queries/tree-queries";
|
||||
import {useDeletePath, useUpdatePath} from "../../../utils/queries/path-queries";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Search, FilePlus2 } from "lucide-react";
|
||||
import { useTree } from "../../../utils/queries/tree-queries";
|
||||
import { useDeletePath, useUpdatePath } from "../../../utils/queries/path-queries";
|
||||
import { Input } from "../../ui/input";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Spinner } from "../../ui/misc";
|
||||
|
||||
const TreeTab = () => {
|
||||
const {data: tree, isLoading, error} = useTree();
|
||||
const { data: tree, isLoading, error } = useTree();
|
||||
const deletePath = useDeletePath();
|
||||
const updatePath = useUpdatePath();
|
||||
const [keyword, setKeyword] = React.useState("");
|
||||
|
||||
const handleDelete = (id) => {
|
||||
if (window.confirm("Are you sure you want to delete this path?")){
|
||||
if (window.confirm("Are you sure you want to delete this path?")) {
|
||||
deletePath.mutate(id, {
|
||||
onError: (err) => {
|
||||
alert("Failed to delete path");
|
||||
},
|
||||
onError: () => alert("Failed to delete path"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filterTree = (t, k) => {
|
||||
if(t === undefined)
|
||||
return undefined;
|
||||
if (t === undefined) return undefined;
|
||||
if (t.type === "path") {
|
||||
if (t.name.includes(k)) {
|
||||
return { ...t };
|
||||
}
|
||||
if (t.name.includes(k)) return { ...t };
|
||||
const filteredChildren = (t.children || [])
|
||||
.map(c => filterTree(c, k))
|
||||
.filter(Boolean);
|
||||
|
||||
if (filteredChildren.length > 0) {
|
||||
return { ...t, children: filteredChildren };
|
||||
}
|
||||
} else if (t.type === "markdown") {
|
||||
if (t.title.includes(k)) {
|
||||
return { ...t };
|
||||
}
|
||||
if (t.title.includes(k)) return { ...t };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -44,35 +42,49 @@ const TreeTab = () => {
|
||||
const filteredTree = filterTree(tree, keyword);
|
||||
|
||||
const handleSave = (id, newName) => {
|
||||
updatePath.mutate({ id, data: {name: newName }} , {
|
||||
onError: (err) => {
|
||||
alert("Failed to update path");
|
||||
}
|
||||
updatePath.mutate({ id, data: { name: newName } }, {
|
||||
onError: () => 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">
|
||||
<input
|
||||
className="input is-small"
|
||||
if (isLoading) return <Spinner label="Loading tree" />;
|
||||
if (error)
|
||||
return (
|
||||
<p className="font-mono text-xs text-destructive">
|
||||
Error loading tree
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 text-xs"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
placeholder="Search…"
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PermissionGuard rolesRequired={["admin", "creator"]}>
|
||||
<a
|
||||
href="/markdown/create"
|
||||
className="button is-primary is-small"
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Create New Markdown
|
||||
</a>
|
||||
<Link to="/markdown/create">
|
||||
<FilePlus2 className="h-4 w-4" /> New Markdown
|
||||
</Link>
|
||||
</Button>
|
||||
</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
|
||||
key={1}
|
||||
path={filteredTree}
|
||||
@@ -80,9 +92,9 @@ const TreeTab = () => {
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TreeTab;
|
||||
export default TreeTab;
|
||||
|
||||
@@ -1,39 +1,5 @@
|
||||
.path-manager-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown .dropdown-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown .dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
/* Styling moved to Tailwind / dark-tech design system in PathManager.js.
|
||||
Only the scroll cap for the suggestions dropdown remains here. */
|
||||
.path-manager-dropdown {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, {useEffect, useState, useRef, useContext} from "react";
|
||||
import {useCreatePath, usePaths} from "../utils/queries/path-queries";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
import "./PathManager.css";
|
||||
import {fetch_} from "../utils/request-utils";
|
||||
import {ConfigContext} from "../ConfigProvider";
|
||||
import { Input } from "./ui/input";
|
||||
import { Button } from "./ui/button";
|
||||
import { Spinner } from "./ui/misc";
|
||||
|
||||
|
||||
const PathManager = ({ currentPathId = 1, onPathChange }) => {
|
||||
@@ -24,10 +28,10 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
|
||||
let current_id = pathId;
|
||||
while (current_id) {
|
||||
try {
|
||||
const pathData = await queryClient.fetchQuery(
|
||||
["path", current_id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/path/${current_id}`)
|
||||
);
|
||||
const pathData = await queryClient.fetchQuery({
|
||||
queryKey: ["path", current_id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/path/${current_id}`)
|
||||
});
|
||||
if (!pathData) break;
|
||||
path.unshift({ name: pathData.name, id: pathData.id });
|
||||
current_id = pathData.parent_id;
|
||||
@@ -71,8 +75,8 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
|
||||
{ name: searchTerm.trim(), parent_id: currentPathId },
|
||||
{
|
||||
onSuccess: (newDir) => {
|
||||
queryClient.setQueryData(["path", newDir.id], newDir);
|
||||
queryClient.invalidateQueries(["paths", currentPathId]);
|
||||
queryClient.setQueryData({queryKey: ["path", newDir.id]}, newDir);
|
||||
queryClient.invalidateQueries({queryKey: ["paths", currentPathId]});
|
||||
setSearchTerm("");
|
||||
alert("Directory created successfully.");
|
||||
},
|
||||
@@ -104,30 +108,26 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="path-manager">
|
||||
<div className="path-manager-header field has-addons">
|
||||
<div className="current-path control">
|
||||
{currentFullPath.map((path, index) => (
|
||||
<span
|
||||
key={path.id}
|
||||
className="tag is-clickable is-link is-light"
|
||||
onClick={() => handlePathClick(path.id, index)}
|
||||
>
|
||||
{path.name + "/"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="control">
|
||||
<span> </span>
|
||||
</div>
|
||||
<div className="path-manager space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{currentFullPath.map((path, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={path.id}
|
||||
className="rounded-md border border-border bg-surface/60 px-2 py-1 font-mono text-xs text-primary transition-colors hover:border-primary/60"
|
||||
onClick={() => handlePathClick(path.id, index)}
|
||||
>
|
||||
{path.name + "/"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="path-manager-body">
|
||||
<div className="field has-addons">
|
||||
<div className="control">
|
||||
<input
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="input is-small"
|
||||
className="h-8 text-xs"
|
||||
type="text"
|
||||
placeholder="Search or create directory"
|
||||
value={searchTerm}
|
||||
@@ -136,42 +136,42 @@ const PathManager = ({ currentPathId = 1, onPathChange }) => {
|
||||
onBlur={handleInputBlur}
|
||||
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 && (
|
||||
<div className="dropdown is-active">
|
||||
<div className="dropdown-menu">
|
||||
<div className="dropdown-content">
|
||||
{dropdownActive && (
|
||||
<div className="path-manager-dropdown absolute left-0 top-full z-20 mt-1 w-full overflow-y-auto rounded-md border border-border bg-card shadow-glow">
|
||||
{filteredSubPaths.length > 0 ? (
|
||||
filteredSubPaths.map((subPath) => (
|
||||
<a
|
||||
<button
|
||||
type="button"
|
||||
key={subPath.id}
|
||||
className="dropdown-item"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-accent"
|
||||
onClick={() => handleSubPathSelect(subPath)}
|
||||
>
|
||||
{subPath.name}
|
||||
</a>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="dropdown-item">No matches found</div>
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">
|
||||
No matches found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleAddDirectory}
|
||||
disabled={isSubPathsLoading || !searchTerm.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Create "{searchTerm}"
|
||||
</Button>
|
||||
</div>
|
||||
{isSubPathsLoading && <Spinner label="Loading paths" />}
|
||||
{subPathsError && (
|
||||
<p className="font-mono text-xs text-destructive">
|
||||
Error loading subdirectories.
|
||||
</p>
|
||||
)}
|
||||
{isSubPathsLoading && <p>Loading...</p>}
|
||||
{subPathsError && <p>Error loading subdirectories.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,14 @@ import {
|
||||
useUpdateMarkdownPermissionSetting
|
||||
} from "../../../utils/queries/markdown-permission-setting-queries";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import { Plus, Save } from "lucide-react";
|
||||
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Label } from "../../ui/input";
|
||||
import { Spinner } from "../../ui/misc";
|
||||
|
||||
const SELECT_CLASS =
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
|
||||
|
||||
const MarkdownPermissionSettingPanel = ({markdownSetting, onClose}) => {
|
||||
const {data: setting, isFetching: settingIsFetching } = useMarkdownPermissionSetting(markdownSetting?.permission_setting_id);
|
||||
@@ -49,42 +56,46 @@ const MarkdownPermissionSettingPanel = ({markdownSetting, onClose}) => {
|
||||
};
|
||||
|
||||
if (settingIsFetching) {
|
||||
return (<p>Loading...</p>);
|
||||
return (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner label="Loading permission" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return setting ? (
|
||||
<div className="box" style={{marginTop: "1rem"}}>
|
||||
<h4 className="title is-5">Permission Setting</h4>
|
||||
<div className="field">
|
||||
<label className="label">Permission</label>
|
||||
<div className="select is-fullwidth">
|
||||
<select
|
||||
value={permission}
|
||||
onChange={(e) => setPermission(e.target.value)}
|
||||
>
|
||||
<option value="">(None)</option>
|
||||
<option value="public">public</option>
|
||||
<option value="protected">protected</option>
|
||||
<option value="private">private</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4 rounded-lg border border-border bg-surface/40 p-5">
|
||||
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
|
||||
Permission Setting
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-select">Permission</Label>
|
||||
<select
|
||||
id="permission-select"
|
||||
className={SELECT_CLASS}
|
||||
value={permission}
|
||||
onChange={(e) => setPermission(e.target.value)}
|
||||
>
|
||||
<option value="">(None)</option>
|
||||
<option value="public">public</option>
|
||||
<option value="protected">protected</option>
|
||||
<option value="private">private</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="button is-primary"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveMarkdownPermissionSetting}
|
||||
>
|
||||
Save Permission Setting
|
||||
</button>
|
||||
<Save className="h-4 w-4" /> Save Permission Setting
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="button is-primary"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreatePermissionSetting}
|
||||
>
|
||||
Create Permission Setting
|
||||
</button>
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Create Permission Setting
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
useUpdateMarkdownTemplateSetting
|
||||
} from "../../../utils/queries/markdown-template-setting-queries";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import { Plus, Save } from "lucide-react";
|
||||
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Label } from "../../ui/input";
|
||||
import { Spinner } from "../../ui/misc";
|
||||
|
||||
const SELECT_CLASS =
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
|
||||
|
||||
const MarkdownTemplateSettingPanel = ({markdownSetting, onClose}) => {
|
||||
const {data: setting, isFetching: settingIsFetching } = useMarkdownTemplateSetting(markdownSetting?.template_setting_id);
|
||||
@@ -51,44 +58,48 @@ const MarkdownTemplateSettingPanel = ({markdownSetting, onClose}) => {
|
||||
}
|
||||
},[template, selectedTemplateId]);
|
||||
if (settingIsFetching || templatesAreFetching || templatesAreFetching || templateIsFetching) {
|
||||
return (<p>Loading...</p>);
|
||||
return (
|
||||
<div className="flex justify-center py-6">
|
||||
<Spinner label="Loading template" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return setting ? (
|
||||
<div className="box" style={{marginTop: "1rem"}}>
|
||||
<h4 className="title is-5">Template Setting</h4>
|
||||
<div className="field">
|
||||
<label className="label">Use Template</label>
|
||||
<div className="select is-fullwidth">
|
||||
<select
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => {
|
||||
setSelectedTemplateId(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">(default)</option>
|
||||
{templates.map((_template, index) => (
|
||||
<option key={index} value={_template.id}>{_template.title}</option>
|
||||
))}
|
||||
<div className="mt-4 space-y-4 rounded-lg border border-border bg-surface/40 p-5">
|
||||
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
|
||||
Template Setting
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="use-template-select">Use Template</Label>
|
||||
<select
|
||||
id="use-template-select"
|
||||
className={SELECT_CLASS}
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => {
|
||||
setSelectedTemplateId(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">(default)</option>
|
||||
{templates.map((_template, index) => (
|
||||
<option key={index} value={_template.id}>{_template.title}</option>
|
||||
))}
|
||||
|
||||
</select>
|
||||
</div>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="button is-primary"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveMarkdownTemplateSetting}
|
||||
>
|
||||
Save Template Setting
|
||||
</button>
|
||||
<Save className="h-4 w-4" /> Save Template Setting
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="button is-primary"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreateTemplateSetting}
|
||||
>
|
||||
Create Template Setting
|
||||
</button>
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Create Template Setting
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,25 @@ import {
|
||||
useDeleteWebhook,
|
||||
} from "../../../utils/queries/webhook-queries";
|
||||
import {useUpdatePathSetting} from "../../../utils/queries/path-setting-queries";
|
||||
import { Plus, Save, Pencil, Trash2, Check } from "lucide-react";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Input, Label } from "../../ui/input";
|
||||
import { Spinner } from "../../ui/misc";
|
||||
|
||||
const SELECT_CLASS =
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
|
||||
|
||||
const CheckboxRow = ({ checked, onChange, label }) => (
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
|
||||
const WebhookSettingPanel = ({pathSetting, onClose}) => {
|
||||
|
||||
@@ -213,219 +232,179 @@ const WebhookSettingPanel = ({pathSetting, onClose}) => {
|
||||
};
|
||||
|
||||
return setting ? (
|
||||
<div className="box" style={{ marginTop: "1rem" }}>
|
||||
<h4 className="title is-5">Webhook Setting</h4>
|
||||
<div className="field">
|
||||
<label className="label">Select or Create a Webhook</label>
|
||||
<div className="field has-addons">
|
||||
<div className="control is-expanded">
|
||||
<div className="mt-4 space-y-5 rounded-lg border border-border bg-surface/40 p-5">
|
||||
<h4 className="font-mono text-sm font-semibold uppercase tracking-wide text-foreground">
|
||||
Webhook Setting
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<Label>Select or Create a Webhook</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{isWebhooksLoading ? (
|
||||
<p>Loading...</p>
|
||||
<Spinner label="Loading webhooks" />
|
||||
) : (
|
||||
<div className="select is-fullwidth">
|
||||
<select
|
||||
value={selectedUrl}
|
||||
onChange={(e) => setSelectedUrl(e.target.value)}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{webhooks.map((hook) => (
|
||||
<option key={hook.id} value={hook.hook_url}>
|
||||
{hook.hook_url}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
value={selectedUrl}
|
||||
onChange={(e) => setSelectedUrl(e.target.value)}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{webhooks.map((hook) => (
|
||||
<option key={hook.id} value={hook.hook_url}>
|
||||
{hook.hook_url}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="control">
|
||||
<button
|
||||
type="button"
|
||||
className="button is-primary"
|
||||
onClick={handleCreateWebhook}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreateWebhook}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
{setting?.webhook_id && (
|
||||
<div className="buttons" style={{ marginTop: "0.5rem" }}>
|
||||
<button
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
className="button is-info"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUpdateWebhook}
|
||||
>
|
||||
Update Webhook URL
|
||||
</button>
|
||||
<button
|
||||
<Pencil className="h-4 w-4" /> Update Webhook URL
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="button is-danger"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteWebhook}
|
||||
>
|
||||
Delete Webhook
|
||||
</button>
|
||||
<Trash2 className="h-4 w-4" /> Delete Webhook
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<CheckboxRow
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
label="Enabled"
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">On Events</label>
|
||||
<div className="box">
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOnMarkdownCreated}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Markdown Created
|
||||
</label>
|
||||
<br />
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOnMarkdownUpdated}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Markdown Updated
|
||||
</label>
|
||||
<br />
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOnMarkdownDeleted}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Markdown Deleted
|
||||
</label>
|
||||
</div>
|
||||
<div className="column">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOnPathCreated}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("PATH_CREATED", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Path Created
|
||||
</label>
|
||||
<br />
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOnPathUpdated}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Path Updated
|
||||
</label>
|
||||
<br />
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOnPathDeleted}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Path Deleted
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>On Events</Label>
|
||||
<div className="grid grid-cols-1 gap-3 rounded-md border border-border bg-background/40 p-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<CheckboxRow
|
||||
checked={isOnMarkdownCreated}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("MARKDOWN_CREATED", e.target.checked)
|
||||
}
|
||||
label="Markdown Created"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={isOnMarkdownUpdated}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked)
|
||||
}
|
||||
label="Markdown Updated"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={isOnMarkdownDeleted}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked)
|
||||
}
|
||||
label="Markdown Deleted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CheckboxRow
|
||||
checked={isOnPathCreated}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("PATH_CREATED", e.target.checked)
|
||||
}
|
||||
label="Path Created"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={isOnPathUpdated}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
|
||||
}
|
||||
label="Path Updated"
|
||||
/>
|
||||
<CheckboxRow
|
||||
checked={isOnPathDeleted}
|
||||
onChange={(e) =>
|
||||
handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
|
||||
}
|
||||
label="Path Deleted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRecursive}
|
||||
onChange={(e) => setIsRecursive(e.target.checked)}
|
||||
/>
|
||||
Recursive
|
||||
</label>
|
||||
</div>
|
||||
<CheckboxRow
|
||||
checked={isRecursive}
|
||||
onChange={(e) => setIsRecursive(e.target.checked)}
|
||||
label="Recursive"
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Additional Headers</label>
|
||||
<div className="box">
|
||||
<div className="space-y-2">
|
||||
<Label>Additional Headers</Label>
|
||||
<div className="space-y-3 rounded-md border border-border bg-background/40 p-4">
|
||||
{headerList.map((h, idx) => (
|
||||
<div className="columns" key={idx}>
|
||||
<div className="column">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="key"
|
||||
value={h.key}
|
||||
onChange={(e) => handleHeaderChange(idx, "key", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="column">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="value"
|
||||
value={h.value}
|
||||
onChange={(e) =>
|
||||
handleHeaderChange(idx, "value", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2" key={idx}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="key"
|
||||
value={h.key}
|
||||
onChange={(e) => handleHeaderChange(idx, "key", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="value"
|
||||
value={h.value}
|
||||
onChange={(e) =>
|
||||
handleHeaderChange(idx, "value", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="button is-small is-info"
|
||||
onClick={handleAddHeader}
|
||||
>
|
||||
+ Header
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button is-small is-success"
|
||||
onClick={handleApplyHeaders}
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddHeader}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Header
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleApplyHeaders}
|
||||
>
|
||||
<Check className="h-4 w-4" /> Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="button is-primary"
|
||||
onClick={handleSaveWebhookSetting}
|
||||
>
|
||||
Save Webhook Setting
|
||||
</button>
|
||||
<Save className="h-4 w-4" /> Save Webhook Setting
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="button is-primary"
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreateWebhookSetting}
|
||||
>
|
||||
Create Webhook Setting
|
||||
</button>
|
||||
<Plus className="h-4 w-4" /> Create Webhook Setting
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
48
src/components/ui/button.js
Normal file
48
src/components/ui/button.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:shadow-glow hover:brightness-110",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:shadow-glow-violet hover:brightness-110",
|
||||
outline:
|
||||
"border border-border bg-transparent text-foreground hover:border-primary/60 hover:text-primary",
|
||||
ghost: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:brightness-110",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-6",
|
||||
icon: "h-9 w-9",
|
||||
"icon-sm": "h-7 w-7",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
}
|
||||
);
|
||||
|
||||
const Button = React.forwardRef(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
49
src/components/ui/card.js
Normal file
49
src/components/ui/card.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-5 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
92
src/components/ui/dialog.js
Normal file
92
src/components/ui/dialog.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm animate-overlay-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogContent = React.forwardRef(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4",
|
||||
"rounded-lg border border-border bg-card p-6 shadow-glow animate-fade-in",
|
||||
"max-h-[90vh] overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:text-foreground focus:outline-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
const DialogHeader = ({ className, ...props }) => (
|
||||
<div className={cn("flex flex-col gap-1.5", className)} {...props} />
|
||||
);
|
||||
|
||||
const DialogFooter = ({ className, ...props }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"font-mono text-lg font-semibold tracking-tight text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = "DialogTitle";
|
||||
|
||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = "DialogDescription";
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
72
src/components/ui/dropdown-menu.js
Normal file
72
src/components/ui/dropdown-menu.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef(
|
||||
({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-glow animate-fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
DropdownMenuContent.displayName = "DropdownMenuContent";
|
||||
|
||||
const DropdownMenuItem = React.forwardRef(
|
||||
({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
|
||||
"focus:bg-accent focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"[&_svg]:size-4 [&_svg]:text-muted-foreground focus:[&_svg]:text-primary",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = "DropdownMenuLabel";
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
};
|
||||
45
src/components/ui/input.js
Normal file
45
src/components/ui/input.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background/60 px-3 py-2 text-sm text-foreground transition-colors",
|
||||
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs font-medium uppercase tracking-wide text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = "Label";
|
||||
|
||||
export { Input, Textarea, Label };
|
||||
88
src/components/ui/misc.js
Normal file
88
src/components/ui/misc.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef(
|
||||
({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs text-foreground shadow-glow animate-fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
TooltipContent.displayName = "TooltipContent";
|
||||
|
||||
const Separator = React.forwardRef(
|
||||
({ className, orientation = "horizontal", ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = "Separator";
|
||||
|
||||
function Badge({ className, variant = "default", ...props }) {
|
||||
const variants = {
|
||||
default: "border-primary/30 bg-primary/10 text-primary",
|
||||
violet: "border-secondary/30 bg-secondary/10 text-secondary",
|
||||
muted: "border-border bg-muted text-muted-foreground",
|
||||
destructive:
|
||||
"border-destructive/30 bg-destructive/10 text-destructive",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider",
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner({ className, label = "Loading" }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="font-mono text-xs uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
Separator,
|
||||
Badge,
|
||||
Spinner,
|
||||
};
|
||||
25
src/components/ui/scroll-area.js
Normal file
25
src/components/ui/scroll-area.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
orientation="vertical"
|
||||
className="flex w-2 touch-none select-none p-0.5 transition-colors"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = "ScrollArea";
|
||||
|
||||
export { ScrollArea };
|
||||
43
src/components/ui/tabs.js
Normal file
43
src/components/ui/tabs.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border border-border bg-surface/60 p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = "TabsList";
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-sm px-3 py-1.5 font-mono text-xs font-medium uppercase tracking-wide transition-all",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"data-[state=active]:bg-primary/15 data-[state=active]:text-primary data-[state=active]:shadow-[0_0_12px_-4px_hsl(var(--primary)/0.6)]",
|
||||
"focus-visible:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn("focus-visible:outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
104
src/globals.css
Normal file
104
src/globals.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import AuthProvider, {AuthContext} from "./AuthProvider";
|
||||
import "bulma/css/bulma.min.css";
|
||||
import "./globals.css";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"
|
||||
import ConfigProvider from "./ConfigProvider";
|
||||
import ControlledReactQueryDevtools from "./components/Debug/ControlledReactQueryDevtools";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -52,6 +54,7 @@ root.render(
|
||||
<AuthProvider>
|
||||
<EnhancedAuthProvider>
|
||||
<App />
|
||||
<ControlledReactQueryDevtools />
|
||||
</EnhancedAuthProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
7
src/lib/utils.js
Normal file
7
src/lib/utils.js
Normal 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));
|
||||
}
|
||||
45
src/utils/pathUtils.js
Normal file
45
src/utils/pathUtils.js
Normal file
@@ -0,0 +1,45 @@
|
||||
export const findMarkdownByPath = (tree, pathString) => {
|
||||
if (!tree || !pathString) return null;
|
||||
|
||||
const pathSegments = pathString.split('/').filter(segment => segment.length > 0);
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
const rootIndex = tree.children?.find(
|
||||
child => child.type === 'markdown' && child.title === 'index'
|
||||
);
|
||||
return rootIndex || null;
|
||||
}
|
||||
let currentNode = tree;
|
||||
|
||||
for (let i = 0; i < pathSegments.length; i++) {
|
||||
const segment = pathSegments[i];
|
||||
|
||||
const childPath = currentNode.children?.find(
|
||||
child => child.type === 'path' && child.name === segment
|
||||
);
|
||||
|
||||
if (!childPath) {
|
||||
if (i === pathSegments.length - 1) {
|
||||
const markdownNode = currentNode.children?.find(
|
||||
child => child.type === 'markdown' && child.title === segment
|
||||
);
|
||||
return markdownNode || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
currentNode = childPath;
|
||||
}
|
||||
|
||||
|
||||
const indexMarkdown = currentNode.children?.find(
|
||||
child => child.type === 'markdown' && child.title === 'index'
|
||||
);
|
||||
|
||||
return indexMarkdown || null;
|
||||
};
|
||||
|
||||
export const getMarkdownIdByPath = (tree, pathString) => {
|
||||
const markdownNode = findMarkdownByPath(tree, pathString);
|
||||
return markdownNode?.id || null;
|
||||
};
|
||||
32
src/utils/queries/apikey-queries.js
Normal file
32
src/utils/queries/apikey-queries.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useConfig } from "../../ConfigProvider";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { fetch_ } from "../request-utils";
|
||||
|
||||
export const useCreateApiKey = () => {
|
||||
const config = useConfig();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, roles }) => {
|
||||
const response = await fetch_(`${config.BACKEND_HOST}/api/apikey/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, roles }),
|
||||
});
|
||||
console.log("response", response);
|
||||
return response;
|
||||
},
|
||||
cacheTime: 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRevokeApiKey = () => {
|
||||
const config = useConfig();
|
||||
return useMutation({
|
||||
mutationFn: async (apiKey) => {
|
||||
const response = await fetch_(`${config.BACKEND_HOST}/api/apikey/revoke`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
return response;
|
||||
},
|
||||
cacheTime: 0,
|
||||
});
|
||||
};
|
||||
@@ -5,13 +5,13 @@ import {fetch_} from "../request-utils";
|
||||
export const useMarkdownPermissionSettings = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery(
|
||||
["markdown_permission_settings"],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/`), {
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const setting of data){
|
||||
queryClient.invalidateQueries(["markdown_permission_setting", setting.id]);
|
||||
return useQuery({
|
||||
queryKey: ["markdown_permission_settings"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/`),
|
||||
onSuccess: async (data) => {
|
||||
if (data) {
|
||||
for (const setting of data) {
|
||||
await queryClient.invalidateQueries(["markdown_permission_setting", setting.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,9 @@ export const useMarkdownPermissionSettings = () => {
|
||||
|
||||
export const useMarkdownPermissionSetting = (setting_id) => {
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["markdown_permission_setting", setting_id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/${setting_id}/`), {
|
||||
return useQuery({
|
||||
queryKey: ["markdown_permission_setting", setting_id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/${setting_id}`),
|
||||
enabled: !!setting_id,
|
||||
}
|
||||
);
|
||||
@@ -32,15 +32,17 @@ export const useMarkdownPermissionSetting = (setting_id) => {
|
||||
export const useCreateMarkdownPermissionSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation((data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["markdown_permission_setting", data.id]);
|
||||
queryClient.invalidateQueries(["markdown_permission_settings"]);
|
||||
}
|
||||
});
|
||||
return useMutation(
|
||||
{
|
||||
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: async (data) => {
|
||||
await queryClient.invalidateQueries(["markdown_permission_setting", data.id]);
|
||||
await queryClient.invalidateQueries(["markdown_permission_settings"]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateMarkdownPermissionSetting = () => {
|
||||
|
||||
@@ -6,71 +6,73 @@ import {useConfig} from "../../ConfigProvider";
|
||||
|
||||
export const useMarkdown = (id) => {
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["markdown", id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/markdown/${id}`),
|
||||
{
|
||||
enabled: !!id,
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: ["markdown", id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useIndexMarkdown = (path_id) => {
|
||||
const queryClient = useQueryClient();
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["index_markdown", path_id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/markdown/get_index/${path_id}`),{
|
||||
enabled: !!path_id,
|
||||
onSuccess: (data) => {
|
||||
if(data && data.id){
|
||||
queryClient.setQueryData(["markdown", data.id], data);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["index_markdown", path_id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/get_index/${path_id}`),
|
||||
enabled: !!path_id,
|
||||
onSuccess: (data) => {
|
||||
if(data && data.id){
|
||||
queryClient.setQueryData({queryKey: ["markdown", data.id]}, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useHomeMarkdown = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["home_markdown"],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/markdown/get_home`), {
|
||||
onSuccess: (data) => {
|
||||
if (data && data.id){
|
||||
queryClient.setQueryData(["markdown", data.id], data);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["home_markdown"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/get_home`),
|
||||
onSuccess: (data) => {
|
||||
if (data && data.id){
|
||||
queryClient.setQueryData({queryKey: ["markdown", data.id]}, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useMarkdownsByPath = (pathId) => {
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["markdownsByPath", pathId],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/markdown/by_path/${pathId}`),
|
||||
{
|
||||
enabled: !!pathId
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: ["markdownsByPath", pathId],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/by_path/${pathId}`),
|
||||
enabled: !!pathId
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useSaveMarkdown = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const config = useConfig();
|
||||
return useMutation(({id, data}) => {
|
||||
const url = id
|
||||
? `${config.BACKEND_HOST}/api/markdown/${id}`
|
||||
: `${config.BACKEND_HOST}/api/markdown/`;
|
||||
const method = id ? "PATCH" : "POST";
|
||||
return fetch_(url, {
|
||||
method,
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},{
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries(["markdownsByPath", res.path_id]);
|
||||
queryClient.invalidateQueries(["markdown", res.id]);
|
||||
queryClient.invalidateQueries(["tree"]);
|
||||
return useMutation({
|
||||
mutationFn: ({id, data}) => {
|
||||
const url = id
|
||||
? `${config.BACKEND_HOST}/api/markdown/${id}`
|
||||
: `${config.BACKEND_HOST}/api/markdown/`;
|
||||
const method = id ? "PATCH" : "POST";
|
||||
return fetch_(url, {
|
||||
method,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
onSuccess: async (res) => {
|
||||
await queryClient.invalidateQueries({queryKey: ["markdownsByPath", res.path_id]});
|
||||
await queryClient.invalidateQueries({queryKey: ["markdown", res.id]});
|
||||
await queryClient.invalidateQueries({queryKey: ["tree"]});
|
||||
|
||||
console.log("invalidateQueries: ", res.id, typeof res.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -80,34 +82,52 @@ export const useMoveMarkdown = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const config = useConfig();
|
||||
|
||||
return useMutation(
|
||||
({markdown, direction}) => {
|
||||
return useMutation({
|
||||
mutationFn: ({markdown, direction}) => {
|
||||
const apiEndpoint = `${config.BACKEND_HOST}/api/markdown/move_${direction}/${markdown.id}`;
|
||||
return fetch_(apiEndpoint, {method: "PATCH"});
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["paths"]);
|
||||
queryClient.invalidateQueries(["tree"]);
|
||||
}
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ["paths"]});
|
||||
queryClient.invalidateQueries({queryKey: ["tree"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteMarkdown = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const config = useConfig();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (markdownId) => {
|
||||
return fetch_(`${config.BACKEND_HOST}/api/markdown/${markdownId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
},
|
||||
onSuccess: (data, markdownId) => {
|
||||
queryClient.invalidateQueries({queryKey: ["markdown", markdownId]});
|
||||
queryClient.invalidateQueries({queryKey: ["tree"]});
|
||||
queryClient.invalidateQueries({queryKey: ["markdownsByPath"]});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useSearchMarkdown = (keyword) => {
|
||||
const config = useConfig();
|
||||
return useQuery(["markdownsByKeyword", keyword],
|
||||
() => fetch_(
|
||||
return useQuery({
|
||||
queryKey: ["markdownsByKeyword", keyword],
|
||||
queryFn: () => fetch_(
|
||||
`${config.BACKEND_HOST}/api/markdown/search/${encodeURIComponent(keyword)}`,
|
||||
),
|
||||
{
|
||||
enabled: !!keyword,
|
||||
}
|
||||
);
|
||||
enabled: !!keyword,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useLinks = () => {
|
||||
const config = useConfig();
|
||||
return useQuery(["links"], () => fetch_(`${config.BACKEND_HOST}/api/markdown/links`));
|
||||
return useQuery({
|
||||
queryKey: ["links"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/markdown/links`)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,71 +5,66 @@ import {fetch_} from "../request-utils";
|
||||
export const useMarkdownSettings = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery(
|
||||
["markdown_setting"],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/`),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const setting of data)
|
||||
queryClient.invalidateQueries(["markdown_setting", setting.id]);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["markdown_setting"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/`),
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const setting of data)
|
||||
queryClient.setQueryData({queryKey: ["markdown_setting", setting.id]}, setting);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkdownSetting = (setting_id) => {
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["markdown_setting", setting_id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/${setting_id}`, {}), {
|
||||
enabled: !!setting_id,
|
||||
}
|
||||
);
|
||||
return useQuery({
|
||||
queryKey: ["markdown_setting", setting_id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/${setting_id}`, {}),
|
||||
enabled: !!setting_id,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useCreateMarkdownSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/`, {
|
||||
return useMutation({
|
||||
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data)
|
||||
}), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["markdown_setting", data.id]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_setting", data.id]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useUpdateMarkdownSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data)
|
||||
}),{
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries(["markdown_setting", variables.id]);
|
||||
}
|
||||
});
|
||||
}),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_setting", variables.id]});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteMarkdownSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(id) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/${id}`, {
|
||||
method: "DELETE",
|
||||
}),{
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["markdown_setting", data.id]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_setting", data.id]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,95 +7,90 @@ import {data} from "react-router-dom";
|
||||
|
||||
export const useMarkdownTemplate = (template_id) => {
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["markdown_template", template_id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/template/markdown/${template_id}`), {
|
||||
enabled: !!template_id,
|
||||
}
|
||||
);
|
||||
return useQuery({
|
||||
queryKey: ["markdown_template", template_id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/template/markdown/${template_id}`),
|
||||
enabled: !!template_id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkdownTemplates = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery(
|
||||
["markdown_templates"],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/template/markdown/`), {
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const template of data){
|
||||
queryClient.setQueryData(["markdown_template", template.id], template);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["markdown_templates"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/template/markdown/`),
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const template of data){
|
||||
queryClient.setQueryData({queryKey: ["markdown_template", template.id]}, template);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateMarkdownTemplate = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({id, data}) => fetch_(`${config.BACKEND_HOST}/api/template/markdown/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/template/markdown/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["markdown_template", data.id]);
|
||||
queryClient.invalidateQueries(["markdown_templates"]);
|
||||
}
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_template", data.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_templates"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const useCreateMarkdownTemplate = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(data) => fetch_(`${config.BACKEND_HOST}/api/template/markdown/`, {
|
||||
return useMutation({
|
||||
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/template/markdown/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),{
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["markdown_template", data.id]);
|
||||
queryClient.invalidateQueries(["markdown_templates"]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_template", data.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_templates"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const useDeleteMarkdownTemplate = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(id) => fetch_(`${config.BACKEND_HOST}/api/template/markdown/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/template/markdown/${id}`, {
|
||||
method: "DELETE"
|
||||
}), {
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries(["markdown_template", variables]);
|
||||
queryClient.invalidateQueries(["markdown_templates"]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_template", variables]});
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_templates"]});
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
export const useSaveMarkdownTemplate = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(({id, data}) => {
|
||||
const url = id
|
||||
? `${config.BACKEND_HOST}/api/template/markdown/${id}`
|
||||
: `${config.BACKEND_HOST}/api/template/markdown/`;
|
||||
const method = id? "PUT": "POST";
|
||||
return fetch_(url, {
|
||||
method,
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},{
|
||||
return useMutation({
|
||||
mutationFn: ({id, data}) => {
|
||||
const url = id
|
||||
? `${config.BACKEND_HOST}/api/template/markdown/${id}`
|
||||
: `${config.BACKEND_HOST}/api/template/markdown/`;
|
||||
const method = id? "PUT": "POST";
|
||||
return fetch_(url, {
|
||||
method,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["markdown_template", data.id]);
|
||||
queryClient.invalidateQueries(["markdown_templates"]);
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_template", data.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_templates"]});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,40 +5,39 @@ import {fetch_} from "../request-utils";
|
||||
export const useMarkdownTemplateSettings = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery(
|
||||
["markdown_template_settings"],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/`), {
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const setting of data){
|
||||
queryClient.invalidateQueries(["markdown_template_setting", settings.id]);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["markdown_template_settings"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/`),
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const setting of data){
|
||||
queryClient.setQueryData({queryKey: ["markdown_template_setting", setting.id]}, setting);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkdownTemplateSetting = (setting_id) => {
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["markdown_template_setting", setting_id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/${setting_id}`), {
|
||||
enabled: !!setting_id,
|
||||
}
|
||||
);
|
||||
return useQuery({
|
||||
queryKey: ["markdown_template_setting", setting_id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/${setting_id}`),
|
||||
enabled: !!setting_id,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useCreateMarkdownTemplateSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation((data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}), {
|
||||
return useMutation({
|
||||
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["markdown_template_setting", data.id]);
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_template_setting", data.id]});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -46,29 +45,27 @@ export const useCreateMarkdownTemplateSetting = () => {
|
||||
export const useUpdateMarkdownTemplateSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),{
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries(["markdown_template_setting", res.id]);
|
||||
queryClient.invalidateQueries(["markdown_template_settings"]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_template_setting", res.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_template_settings"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteMarkdownTemplateSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({id}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: ({id}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/template/${id}`, {
|
||||
method: "DELETE",
|
||||
}), {
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries(["markdown_template_setting", variables.id]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: ["markdown_template_setting", variables.id]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
61
src/utils/queries/patch-queries.js
Normal file
61
src/utils/queries/patch-queries.js
Normal 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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -6,87 +6,77 @@ import {useConfig} from "../../ConfigProvider";
|
||||
export const usePaths = (parent_id) => {
|
||||
const queryClient = useQueryClient();
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["paths", parent_id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/path/parent/${parent_id}`),
|
||||
{
|
||||
enabled: !!parent_id,
|
||||
onSuccess: (data) => {
|
||||
if(data) {
|
||||
for (const pth of data)
|
||||
{
|
||||
queryClient.setQueryData(["path", pth.id], pth);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["paths", parent_id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/path/parent/${parent_id}`),
|
||||
enabled: !!parent_id,
|
||||
onSuccess: (data) => {
|
||||
if(data) {
|
||||
for (const pth of data)
|
||||
{
|
||||
queryClient.setQueryData({queryKey: ["path", pth.id]}, pth);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const usePath = (id) => {
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["path", id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/path/${id}`),
|
||||
{
|
||||
enabled: !!id
|
||||
}
|
||||
);
|
||||
return useQuery({
|
||||
queryKey: ["path", id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/path/${id}`),
|
||||
enabled: !!id
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreatePath = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
(data) => fetch_(`${config.BACKEND_HOST}/api/path/`, {
|
||||
return useMutation({
|
||||
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/path/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries(["paths", res.parent_id]);
|
||||
queryClient.invalidateQueries(["tree"]);
|
||||
},
|
||||
}
|
||||
);
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({queryKey: ["paths", res.parent_id]});
|
||||
queryClient.invalidateQueries({queryKey: ["tree"]});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePath = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
({ id, data }) => fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }) => fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries(["paths", res.parent_id]);
|
||||
queryClient.invalidateQueries(["path", res.id]);
|
||||
queryClient.invalidateQueries(["tree"]);
|
||||
},
|
||||
}
|
||||
);
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({queryKey: ["paths", res.parent_id]});
|
||||
queryClient.invalidateQueries({queryKey: ["path", res.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["tree"]});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePath = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
(id) => fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/path/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["paths"]);
|
||||
queryClient.invalidateQueries(["tree"]);
|
||||
},
|
||||
}
|
||||
);
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ["paths"]});
|
||||
queryClient.invalidateQueries({queryKey: ["tree"]});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -94,16 +84,14 @@ export const useMovePath = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const config = useConfig();
|
||||
|
||||
return useMutation(
|
||||
({path, direction}) => {
|
||||
return useMutation({
|
||||
mutationFn: ({path, direction}) => {
|
||||
const apiEndpoint = `${config.BACKEND_HOST}/api/path/move_${direction}/${path.id}`;
|
||||
return fetch_(apiEndpoint, {method: "PATCH"});
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["paths"]);
|
||||
queryClient.invalidateQueries(["tree"]);
|
||||
}
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ["paths"]});
|
||||
queryClient.invalidateQueries({queryKey: ["tree"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,76 +5,69 @@ import {fetch_} from "../request-utils";
|
||||
export const usePathSettings = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery(
|
||||
"path_settings",
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/path/`),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const setting of data)
|
||||
queryClient.setQueryData(["path_setting", setting.id], setting);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["path_settings"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/path/`),
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const setting of data)
|
||||
queryClient.setQueryData({queryKey: ["path_setting", setting.id]}, setting);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const usePathSetting = (setting_id) => {
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["path_setting", setting_id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/path/${setting_id}`),
|
||||
{
|
||||
enabled: !!setting_id,
|
||||
}
|
||||
);
|
||||
return useQuery({
|
||||
queryKey: ["path_setting", setting_id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/path/${setting_id}`),
|
||||
enabled: !!setting_id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreatePathSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(data) => fetch_(`${config.BACKEND_HOST}/api/setting/path/`, {
|
||||
return useMutation({
|
||||
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/path/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data)
|
||||
}), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(["path_setting", data.id]);
|
||||
queryClient.invalidateQueries(["path_settings"]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({queryKey: ["path_setting", data.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["path_settings"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useUpdatePathSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/path/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/path/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data)
|
||||
}), {
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries(["path_setting", variables.id]);
|
||||
queryClient.invalidateQueries(["path_settings"]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: ["path_setting", variables.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["path_settings"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useDeletePathSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(id) => fetch_(`${config.BACKEND_HOST}/api/setting/path/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/setting/path/${id}`, {
|
||||
method: "DELETE",
|
||||
}),{
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries(["path_setting", variables.id]);
|
||||
queryClient.invalidateQueries(["path_settings"]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: ["path_setting", variables.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["path_settings"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,14 +6,12 @@ import {useConfig} from "../../ConfigProvider";
|
||||
export const useTree = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["tree"],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/tree/`),
|
||||
{
|
||||
onSuccess: data => {
|
||||
if(data)
|
||||
queryClient.setQueryData(["tree"], data);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["tree"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/tree/`),
|
||||
onSuccess: data => {
|
||||
if(data)
|
||||
queryClient.setQueryData({queryKey: ["tree"]}, data);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,147 +5,132 @@ import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query";
|
||||
export const useWebhooks = () =>{
|
||||
const queryClient = useQueryClient();
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["webhooks"],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/webhook/`),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const webhook of data){
|
||||
queryClient.setQueryData(["webhook", data.id], data);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["webhooks"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/webhook/`),
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const webhook of data){
|
||||
queryClient.setQueryData({queryKey: ["webhook", data.id]}, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateWebhook = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
(data) => fetch_(`${config.BACKEND_HOST}/api/webhook/`, {
|
||||
return useMutation({
|
||||
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/webhook/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
"hook_url": data
|
||||
}),
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["webhooks"]);
|
||||
}
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ["webhooks"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateWebhook = () =>{
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({id, data}) => fetch_(`${config.BACKEND_HOST}/api/webhook/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/webhook/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries(["webhook", res.id]);
|
||||
queryClient.invalidateQueries(["webhooks"]);
|
||||
}
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({queryKey: ["webhook", res.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["webhooks"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteWebhook = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(id) => fetch_(`${config.BACKEND_HOST}/api/webhook/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/webhook/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries(["webhook", variables.id]);
|
||||
queryClient.invalidateQueries(["webhooks"]);
|
||||
}
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: ["webhook", variables.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["webhooks"]});
|
||||
}
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
export const useWebhookSettings = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery(
|
||||
["webhook_setting"],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/`),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const setting of data){
|
||||
queryClient.setQueryData(["webhook_setting", setting.id], setting);
|
||||
}
|
||||
return useQuery({
|
||||
queryKey: ["webhook_setting"],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/`),
|
||||
onSuccess: (data) => {
|
||||
if(data){
|
||||
for(const setting of data){
|
||||
queryClient.setQueryData({queryKey: ["webhook_setting", setting.id]}, setting);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useWebhookSetting = (setting_id) => {
|
||||
const config = useConfig();
|
||||
return useQuery(
|
||||
["webhook_setting", setting_id],
|
||||
() => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/${setting_id}`),
|
||||
{
|
||||
enabled: !!setting_id,
|
||||
});
|
||||
return useQuery({
|
||||
queryKey: ["webhook_setting", setting_id],
|
||||
queryFn: () => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/${setting_id}`),
|
||||
enabled: !!setting_id,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useCreateWebhookSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(data) => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/`, {
|
||||
return useMutation({
|
||||
mutationFn: (data) => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data)
|
||||
}),{
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries(["webhook_setting", res.id]);
|
||||
queryClient.invalidateQueries(["webhook_setting"]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({queryKey: ["webhook_setting", res.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["webhook_setting"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateWebhookSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: ({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data)
|
||||
}),{
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries(["webhook_setting", variables.id]);
|
||||
queryClient.invalidateQueries(["webhook_setting"]);
|
||||
}
|
||||
}),
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: ["webhook_setting", variables.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["webhook_setting"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useDeleteWebhookSetting = () => {
|
||||
const config = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(id) => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/${id}`, {
|
||||
return useMutation({
|
||||
mutationFn: (id) => fetch_(`${config.BACKEND_HOST}/api/setting/path/webhook/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries(["webhook_setting", variables.id]);
|
||||
queryClient.invalidateQueries(["webhook_setting"]);
|
||||
}
|
||||
onSuccess: (res, variables) => {
|
||||
queryClient.invalidateQueries({queryKey: ["webhook_setting", variables.id]});
|
||||
queryClient.invalidateQueries({queryKey: ["webhook_setting"]});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
11
src/utils/safe-json.js
Normal file
11
src/utils/safe-json.js
Normal 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
74
tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
@@ -5,7 +5,11 @@ module.exports = {
|
||||
entry: './src/index.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, './dist'),
|
||||
filename: 'bundle.js',
|
||||
// Content-hashed filename: index.html (injected by HtmlWebpackPlugin,
|
||||
// and not edge-cached) points at a unique URL each build, so a new
|
||||
// deploy is picked up immediately — no stale CDN/browser bundle.
|
||||
filename: '[name].[contenthash].js',
|
||||
chunkFilename: '[name].[contenthash].js',
|
||||
publicPath: '/',
|
||||
clean: true,
|
||||
},
|
||||
@@ -20,7 +24,7 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
use: ['style-loader', 'css-loader', 'postcss-loader'],
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user