feat: dark-tech UI redesign + markdown patch cards

Redesign the frontend with a dark-tech theme: add Tailwind + PostCSS,
design tokens, and shadcn-style primitives (Button/Card/Input/Dialog/
DropdownMenu/Tabs/ScrollArea/etc.); restyle the app shell, navigation,
sidebar tree, content view, markdown rendering, editors, modals and
settings panels. Behavior/props unchanged; Font Awesome replaced with
lucide-react.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 17:28:13 +01:00
parent 045c7c51d6
commit 952387d50f
54 changed files with 4503 additions and 1765 deletions

View File

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

View File

@@ -13,43 +13,45 @@ 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>
<Routes>
<Route path="/pg/*" element={<StandaloneMarkdownPage />} />
<Route path="*" element={
<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/:strId" 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/: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>
</main>
<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>
</div>
} />
</Routes>
<Footer />
</Router>
} />
</Routes>
</Router>
</TooltipProvider>
</Provider>
);
};

View File

@@ -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>&copy; {new Date().getFullYear()} Hangman Lab. {!isVisible && (<span>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="mailto:hzhang@hangman-lab.top">email</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://git.hangman-lab.top/hzhang/HangmanLab">git</a>
&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;
</span>
)}</p>
{
isVisible && (
<div
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>
);

View File

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

View File

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

View File

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

View File

@@ -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>
);
};

View File

@@ -1,11 +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"
style={{ height: "60vh" }}
<Textarea
className="h-[60vh] font-mono text-sm"
value={_layout}
onChange={(e) => {
setLayout(e.target.value);

View File

@@ -1,10 +1,13 @@
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 = () => {
@@ -27,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 } },
@@ -47,51 +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)}
/>
</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>
<ParametersManager
<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={(newParameters) => setParameters(newParameters)}
onChange={(newLayout) => setLayout(newLayout)}
/>
</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>
);
};

View File

@@ -1,5 +1,8 @@
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 || []);
@@ -10,7 +13,7 @@ const ParametersManager = ({ parameters, onChange }) => {
..._parameters,
{
name: "",
type: {
type: {
base_type: "string",
definition: {}
}
@@ -59,56 +62,53 @@ const ParametersManager = ({ parameters, onChange }) => {
};
return (
<div className="box">
<div className="field">
<div className="control">
<button className="button is-primary" onClick={handleAdd}>
Add Parameter
</button>
</div>
</div>
<div style={{ maxHeight: "50vh", overflowY: "auto" }}>
<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="box" style={{ marginBottom: "0.5rem" }}>
<div className="field is-grouped is-align-items-end">
<div className="control">
<label className="label">Name:</label>
</div>
<div className="control is-expanded">
<input
<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"
className="input"
value={param.name}
onChange={(e) => handleNameChange(index, e.target.value)}
placeholder="Parameter name"
/>
</div>
<div className="control">
<button
className="button is-danger"
onClick={() => handleDelete(index)}
>
Delete
</button>
</div>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => handleDelete(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="field">
<div className="is-flex is-justify-content-space-between is-align-items-center mb-1">
<label className="label mb-0">Type:</label>
<button
className="button is-small"
<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] ? "-" : "+"}
</button>
{expandedStates[index] ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
{expandedStates[index] && (
<div className="control">
<TypeEditor
type={param.type}
onChange={(newType) => handleTypeChange(index, newType)}
/>
</div>
<TypeEditor
type={param.type}
onChange={(newType) => handleTypeChange(index, newType)}
/>
)}
</div>
</div>

View File

@@ -1,5 +1,10 @@
import React, { useEffect, useState } from "react";
import { useMarkdownTemplates } from "../../utils/queries/markdown-template-queries";
import { Label } from "../ui/input";
import { Spinner } from "../ui/misc";
const SELECT_CLASS =
"flex h-9 w-full rounded-md border border-input bg-background/60 px-3 py-1 text-sm text-foreground transition-colors focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring/40";
const TemplateSelector = ({ template, onChange, onCreate }) => {
const { data: templates, isFetching: templatesAreFetching } = useMarkdownTemplates();
@@ -22,38 +27,36 @@ const TemplateSelector = ({ template, onChange, onCreate }) => {
}, [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);
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>
</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>
);
};

View File

@@ -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,70 +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">
<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,
},
});
}}
@@ -86,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>
);

View File

@@ -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. */

View File

@@ -2,7 +2,9 @@ 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";
@@ -11,6 +13,8 @@ import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries
import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
import { parseMarkdownContent } from "../../utils/safe-json";
import { Button } from "../ui/button";
import { Spinner } from "../ui/misc";
const MarkdownContent = () => {
const { strId } = useParams();
@@ -33,49 +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="is-flex is-justify-content-space-between is-align-items-center markdown-content-container-header">
<h1 className="title">{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']}>
<div className="field has-addons">
<button
className="control button is-info is-light"
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSettingModalOpen(true)}
>
Settings
</button>
<Link to={`/markdown/edit/${id}`} className="control button is-primary is-light">
Edit
</Link>
<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={parseMarkdownContent(markdown.content)} template={template}/>
<MarkdownSettingModal
<PatchCards markdownId={id} />
<MarkdownSettingModal
isOpen={isSettingModalOpen}
markdown={markdown}
onClose={() => setSettingModalOpen(false)}
/>
</div>
</article>
);
};

View File

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

View File

@@ -12,6 +12,10 @@ 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);
@@ -184,7 +188,11 @@ const MarkdownEditor = () => {
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("=============");
@@ -194,120 +202,130 @@ 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={selectedTemplate || 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: "40vh"}}
content={content}
template={!markdown?.id ? selectedTemplate : 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={!markdown?.id ? selectedTemplate : template}
<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>

View File

@@ -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));
}

View File

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

View File

@@ -10,6 +10,7 @@ import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-templat
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();
@@ -53,44 +54,53 @@ const StandaloneMarkdownPage = () => {
if (notReady) {
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<div>Loading...</div>
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Loading" />
</div>
);
}
if (error) {
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<div>Error: {error.message || "Failed to load content"}</div>
<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 style={{ padding: "2rem", textAlign: "center" }}>
<div>Markdown not found for path: {pathString}</div>
<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 style={{ padding: "2rem" }}>
<div className="notification is-info">
<h4 className="title is-4">{markdown.title}</h4>
<p>{markdown.content}</p>
<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 style={{ padding: "2rem", maxWidth: "100%", margin: "0 auto" }}>
<div style={{ marginBottom: "2rem" }}>
<h1 className="title">{markdown?.title === "index" ? indexTitle : markdown?.title}</h1>
</div>
<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} />
)}

View File

@@ -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,50 +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
style={{maxHeight: "10vh"}}
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>
);
@@ -80,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}
@@ -92,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) => {
@@ -129,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]}
@@ -161,17 +157,11 @@ const TemplatedEditor = ({ content, template, onContentChanged, style }) => {
};
return (
<div className="box" style={{
...style,
display: "flex",
flexDirection: "column",
overflow: "hidden"
}}>
<div style={{
flex: 1,
overflowY: "auto",
padding: "1rem"
}}>
<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}
@@ -186,4 +176,4 @@ const TemplatedEditor = ({ content, template, onContentChanged, style }) => {
);
};
export default TemplatedEditor;
export default TemplatedEditor;

View File

@@ -1,8 +1,21 @@
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']);
@@ -54,111 +67,102 @@ const ApiKeyCreationModal = ({ isOpen, onClose }) => {
};
const getRemainingRoles = (currentIndex) => {
return AVAILABLE_ROLES.filter(role =>
return AVAILABLE_ROLES.filter(role =>
!roles.find((r, i) => r === role && i !== currentIndex)
);
};
if (!isOpen) return null;
return (
<div className="modal is-active">
<div className="modal-background" onClick={onClose}></div>
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">Create API Key</p>
<button className="delete" aria-label="close" onClick={onClose}></button>
</header>
<section className="modal-card-body">
{!generatedKey ? (
<div>
<div className="field">
<label className="label">Name</label>
<div className="control">
<input
className="input"
type="text"
placeholder="API key name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className="field">
<label className="label">Roles</label>
<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="field has-addons">
<div className="control">
<div className="select">
<select
value={role}
onChange={(e) => handleRoleChange(index, e.target.value)}
>
{getRemainingRoles(index).map(availableRole => (
<option key={availableRole} value={availableRole}>
{availableRole}
</option>
))}
</select>
</div>
</div>
<div className="control">
<button
className="button is-danger"
onClick={() => handleRemoveRole(index)}
disabled={roles.length === 1}
>
Delete
</button>
</div>
<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>
))}
<button
className="button is-info mt-2"
onClick={handleAddRole}
disabled={roles.length === AVAILABLE_ROLES.length}
>
Add Role
</button>
</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="notification is-warning">
Please copy your API key immediately! It will only be displayed once!
</div>
<div className="field">
<label className="label">Your API Key:</label>
<div className="control">
<input
className="input"
type="text"
value={generatedKey.key}
readOnly
/>
</div>
</div>
<button className="button is-info" onClick={handleCopy}>
Copy
</button>
</div>
) : (
<div 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>
)}
</section>
<footer className="modal-card-foot">
<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
className="button is-primary"
<Button
onClick={handleGenerate}
disabled={createApiKeyMutation.isLoading || !name.trim()}
>
Generate
</button>
<KeyRound className="h-4 w-4" /> Generate
</Button>
)}
<button className="button" onClick={onClose}>Close</button>
</footer>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ApiKeyCreationModal;
export default ApiKeyCreationModal;

View File

@@ -1,5 +1,15 @@
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('');
@@ -21,43 +31,37 @@ const ApiKeyRevokeModal = ({ isOpen, onClose }) => {
}
};
if (!isOpen) return null;
return (
<div className="modal is-active">
<div className="modal-background" onClick={onClose}></div>
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">Revoke API Key</p>
<button className="delete" aria-label="close" onClick={onClose}></button>
</header>
<section className="modal-card-body">
<div className="field">
<label className="label">API Key</label>
<div className="control">
<input
className="input"
type="text"
placeholder="Enter API key to revoke"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</div>
</section>
<footer className="modal-card-foot">
<button
className="button is-danger"
<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}
>
Revoke
</button>
<button className="button" onClick={onClose}>Cancel</button>
</footer>
</div>
</div>
<Trash2 className="h-4 w-4" /> Revoke
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ApiKeyRevokeModal;
export default ApiKeyRevokeModal;

View File

@@ -1,4 +1,14 @@
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 = () => {
@@ -6,37 +16,27 @@ const JsonSchemaModal = ({ isActive, onClose, schema }) => {
};
return (
<div className={`modal ${isActive ? 'is-active' : ''}`}>
<div className="modal-background" onClick={onClose}></div>
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">JSON Schema</p>
<button className="delete" aria-label="close" onClick={onClose}></button>
</header>
<section className="modal-card-body">
<div className="field">
<div className="control">
<textarea
className="textarea"
value={JSON.stringify(schema, null, 2)}
readOnly
style={{ height: "50vh" }}
/>
</div>
</div>
</section>
<footer className="modal-card-foot">
<button className="button is-primary" onClick={handleCopy}>
<span className="icon">
<i className="fas fa-copy"></i>
</span>
<span>copy</span>
</button>
<button className="button" onClick={onClose}>close</button>
</footer>
</div>
</div>
<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;
export default JsonSchemaModal;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -1,46 +1,67 @@
import React, {useContext, useState} from "react";
import React, { useContext, useState } from "react";
import { Link } from "react-router-dom";
import { 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);
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
if (config===undefined) {
return <div>Loading ...</div>;
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();
@@ -56,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];
}
@@ -83,10 +107,10 @@ 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");
}
@@ -94,114 +118,98 @@ const MainNavigation = () => {
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>
<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"
>
<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>
<nav className="ml-2 hidden items-center gap-1 sm:flex">
<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");
}}
href="https://mail.hangman-lab.top"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<Mail className="h-3.5 w-3.5" /> Mail
</a>
</div>
<a
href="https://git.hangman-lab.top"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<GitBranch className="h-3.5 w-3.5" /> Git
</a>
</nav>
<div id="navbarBasicExample" className="navbar-menu">
<div className="navbar-start">
<a
href="https://mail.hangman-lab.top"
className="navbar-item"
target="_blank"
rel="noopener noreferrer"
>
MailBox
</a>
<a
href="https://git.hangman-lab.top"
className="navbar-item"
target="_blank"
rel="noopener noreferrer"
>
Git
</a>
</div>
<div className="navbar-end">
{user && user.profile ? (
<div className="navbar-item has-dropdown is-hoverable">
<div className="buttons">
<span
className="button is-primary is-light"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<div className="ml-auto flex items-center">
{user && user.profile ? (
<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>
<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-info dropdown-option"
onClick={() => setIsApiKeyModalOpen(true)}
type="button"
>
Create API Key
</button>
<button
className="button is-warning dropdown-option"
onClick={() => setIsRevokeModalOpen(true)}
type="button"
>
Revoke API Key
</button>
<button
className="button is-danger dropdown-option"
onClick={logout}
type="button"
>
Logout
</button>
</div>
</div>
</div>
) : (
<div className="navbar-item">
<button
className="button is-primary"
onClick={login}
type="button"
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{user.profile.name}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleGetBackup}>
<Download /> Get Backup
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLoadBackup}>
<Upload /> Load Backup
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setIsApiKeyModalOpen(true)}
>
Login
</button>
</div>
)}
</div>
<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>
) : (
<Button size="sm" onClick={login} className="gap-2">
<LogIn className="h-4 w-4" /> Login
</Button>
)}
</div>
</nav>
</header>
<ApiKeyCreationModal
isOpen={isApiKeyModalOpen}
onClose={() => setIsApiKeyModalOpen(false)}
@@ -214,4 +222,4 @@ const MainNavigation = () => {
);
};
export default MainNavigation;
export default MainNavigation;

View File

@@ -1,23 +1,27 @@
import {Link, useNavigate} 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";
import {useDeleteMarkdown} from "../../utils/queries/markdown-queries";
import {useDeleteMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
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('/');
}
@@ -25,56 +29,54 @@ const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
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>
<Settings2 className="h-3.5 w-3.5" />
</button>
</p>
<p className="control">
<button
className="button is-small is-danger"
onClick={handleDeleteMarkdown}
type="button"
className={cn(iconBtn, "hover:text-destructive")}
title="Delete"
onClick={handleDeleteMarkdown}
disabled={deleteMarkdown.isLoading || deleteMarkdownSetting.isLoading}
>
<span className="icon">
<i className="fas fa-trash"/>
</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"
>
</button>
<button
className="button is-small mb-1 move-backward"
style={{height: "1rem", padding: "0.25rem"}}
onClick={() => handleMoveMarkdown(markdown, "backward")}
type="button"
>
<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}
@@ -83,9 +85,8 @@ const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
/>
</PermissionGuard>
</div>
</li>
);
}
};
export default MarkdownNode;
export default MarkdownNode;

View File

@@ -1,13 +1,27 @@
import React, {useState} from "react";
import React, { useState } from "react";
import { useSelector, useDispatch } from 'react-redux';
import { 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}

View File

@@ -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>
);
};

View File

@@ -1,8 +1,12 @@
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();
@@ -111,53 +115,59 @@ const TemplateTab = () => {
return schema;
};
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading templates</p>;
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>
<ul className="menu-list">
<ul className="space-y-0.5">
{filteredTemplates?.map((template) => (
<li key={template.id}>
<div className="is-flex is-justify-content-space-between is-align-items-center">
<span>{template.title}</span>
<div className="field has-addons is-justify-content-flex-end">
<button
className="button is-small control"
onClick={() => handleTemplateClick(template.id)}
type="button"
>
<span className="icon">
<i className="fas fa-edit"></i>
</span>
</button>
<button
className="button is-small control"
onClick={() => setSelectedSchema(generateJsonSchema(template))}
type="button"
>
<span className="icon">
<i className="fas fa-code"></i>
</span>
</button>
</div>
<li
key={template.id}
className="group flex items-center gap-1 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/60"
>
<LayoutTemplate className="h-3.5 w-3.5 shrink-0 text-secondary/80" />
<span className="min-w-0 flex-1 truncate text-sm text-foreground/90">
{template.title}
</span>
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
title="Edit"
onClick={() => handleTemplateClick(template.id)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="grid h-6 w-6 place-items-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-primary"
title="JSON schema"
onClick={() => setSelectedSchema(generateJsonSchema(template))}
>
<Braces className="h-3.5 w-3.5" />
</button>
</div>
</li>
))}
@@ -167,7 +177,7 @@ const TemplateTab = () => {
onClose={() => setSelectedSchema(null)}
schema={selectedSchema}
/>
</aside>
</div>
);
};

View File

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

View File

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

View File

@@ -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 }) => {
@@ -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>&nbsp;&nbsp;&nbsp;&nbsp;</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>
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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)}
/>
&nbsp; 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)
}
/>
&nbsp; Markdown Created
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnMarkdownUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_UPDATED", e.target.checked)
}
/>
&nbsp; Markdown Updated
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnMarkdownDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("MARKDOWN_DELETED", e.target.checked)
}
/>
&nbsp; Markdown Deleted
</label>
</div>
<div className="column">
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathCreated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_CREATED", e.target.checked)
}
/>
&nbsp; Path Created
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathUpdated}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_UPDATED", e.target.checked)
}
/>
&nbsp; Path Updated
</label>
<br />
<label className="checkbox">
<input
type="checkbox"
checked={isOnPathDeleted}
onChange={(e) =>
handleTriggerEventsUpdate("PATH_DELETED", e.target.checked)
}
/>
&nbsp; Path Deleted
</label>
</div>
<div 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)}
/>
&nbsp; 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>
);
}

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

104
src/globals.css Normal file
View File

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

View File

@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import 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";

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

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

View File

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