Compare commits

...

3 Commits

Author SHA1 Message Date
c9310250e4 add: markdown deletion 2025-06-23 15:41:03 +01:00
a08164e914 add: route to stand along page 2025-06-23 12:18:26 +01:00
30a46d5064 add: markdown template to json schema 2025-05-12 09:59:23 +01:00
10 changed files with 424 additions and 51 deletions

View File

@@ -6,6 +6,7 @@ import MainNavigation from "./components/Navigations/MainNavigation";
import SideNavigation from "./components/Navigations/SideNavigation"; import SideNavigation from "./components/Navigations/SideNavigation";
import MarkdownContent from "./components/Markdowns/MarkdownContent"; import MarkdownContent from "./components/Markdowns/MarkdownContent";
import MarkdownEditor from "./components/Markdowns/MarkdownEditor"; import MarkdownEditor from "./components/Markdowns/MarkdownEditor";
import StandaloneMarkdownPage from "./components/Markdowns/StandaloneMarkdownPage";
import "./App.css"; import "./App.css";
import Callback from "./components/KeycloakCallbacks/Callback"; import Callback from "./components/KeycloakCallbacks/Callback";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
@@ -18,6 +19,9 @@ const App = () => {
return ( return (
<Provider store={store}> <Provider store={store}>
<Router> <Router>
<Routes>
<Route path="/pg/*" element={<StandaloneMarkdownPage />} />
<Route path="*" element={
<div className="app-container"> <div className="app-container">
<MainNavigation /> <MainNavigation />
<div className="content-container"> <div className="content-container">
@@ -42,6 +46,8 @@ const App = () => {
</main> </main>
</div> </div>
</div> </div>
} />
</Routes>
<Footer /> <Footer />
</Router> </Router>
</Provider> </Provider>

View File

@@ -70,9 +70,11 @@ const ParametersManager = ({ parameters, onChange }) => {
<div style={{ maxHeight: "50vh", overflowY: "auto" }}> <div style={{ maxHeight: "50vh", overflowY: "auto" }}>
{_parameters.map((param, index) => ( {_parameters.map((param, index) => (
<div key={index} className="box" style={{ marginBottom: "0.5rem" }}> <div key={index} className="box" style={{ marginBottom: "0.5rem" }}>
<div className="field is-grouped is-grouped-multiline"> <div className="field is-grouped is-align-items-end">
<div className="control">
<label className="label">Name:</label>
</div>
<div className="control is-expanded"> <div className="control is-expanded">
<label className="label mb-1">Name:</label>
<input <input
type="text" type="text"
className="input" className="input"

View File

@@ -69,7 +69,6 @@ const TypeEditor = ({ type, onChange }) => {
case 'template': case 'template':
return ( return (
<div className="field"> <div className="field">
<label className="label">Template</label>
<div className="control"> <div className="control">
<TemplateSelector <TemplateSelector
template={_type.definition.template} template={_type.definition.template}
@@ -95,7 +94,7 @@ const TypeEditor = ({ type, onChange }) => {
return ( return (
<div className="box"> <div className="box">
<div className="field"> <div className="field">
<label className="label">Type</label> {/*<label className="label">Type</label>*/}
<div className="control"> <div className="control">
<div className="select is-fullwidth"> <div className="select is-fullwidth">
<select <select

View File

@@ -9,11 +9,13 @@ import {usePath} from "../../utils/queries/path-queries";
import {useMarkdownSetting} from "../../utils/queries/markdown-setting-queries"; import {useMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries"; import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries"; import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
const MarkdownContent = () => { const MarkdownContent = () => {
const { strId } = useParams(); const { strId } = useParams();
const id = Number(strId); const id = Number(strId);
const [indexTitle, setIndexTitle] = useState(null); const [indexTitle, setIndexTitle] = useState(null);
const [isSettingModalOpen, setSettingModalOpen] = useState(false);
const {data: markdown, isLoading, error} = useMarkdown(id); const {data: markdown, isLoading, error} = useMarkdown(id);
const {data: path, isFetching: isPathFetching} = usePath(markdown?.path_id); const {data: path, isFetching: isPathFetching} = usePath(markdown?.path_id);
const {data: setting, isFetching: isSettingFetching} = useMarkdownSetting(markdown?.setting_id); const {data: setting, isFetching: isSettingFetching} = useMarkdownSetting(markdown?.setting_id);
@@ -50,15 +52,28 @@ const MarkdownContent = () => {
return ( return (
<div className="markdown-content-container"> <div className="markdown-content-container">
<div className="field has-addons markdown-content-container-header"> <div className="is-flex is-justify-content-space-between is-align-items-center markdown-content-container-header">
<h1 className="title control">{markdown.title === "index" ? indexTitle : markdown.title}</h1> <h1 className="title">{markdown.title === "index" ? indexTitle : markdown.title}</h1>
<PermissionGuard rolesRequired={['admin']}> <PermissionGuard rolesRequired={['admin']}>
<div className="field has-addons">
<button
className="control button is-info is-light"
onClick={() => setSettingModalOpen(true)}
>
Settings
</button>
<Link to={`/markdown/edit/${id}`} className="control button is-primary is-light"> <Link to={`/markdown/edit/${id}`} className="control button is-primary is-light">
Edit Edit
</Link> </Link>
</div>
</PermissionGuard> </PermissionGuard>
</div> </div>
<MarkdownView content={JSON.parse(markdown.content)} template={template}/> <MarkdownView content={JSON.parse(markdown.content)} template={template}/>
<MarkdownSettingModal
isOpen={isSettingModalOpen}
markdown={markdown}
onClose={() => setSettingModalOpen(false)}
/>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,100 @@
import React, { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import "katex/dist/katex.min.css";
import "./MarkdownContent.css";
import MarkdownView from "./MarkdownView";
import { useMarkdown } from "../../utils/queries/markdown-queries";
import { useMarkdownSetting } from "../../utils/queries/markdown-setting-queries";
import { useMarkdownTemplate } from "../../utils/queries/markdown-template-queries";
import { useMarkdownTemplateSetting } from "../../utils/queries/markdown-template-setting-queries";
import { useTree } from "../../utils/queries/tree-queries";
import { getMarkdownIdByPath } from "../../utils/pathUtils";
const StandaloneMarkdownPage = () => {
const location = useLocation();
const [indexTitle, setIndexTitle] = useState(null);
const [markdownId, setMarkdownId] = useState(null);
// Extract path from /pg/project/index -> project/index
const pathString = location.pathname.replace(/^\/pg\//, '');
const { data: tree, isLoading: isTreeLoading } = useTree();
const { data: markdown, isLoading: isMarkdownLoading, error } = useMarkdown(markdownId);
const { data: setting, isFetching: isSettingFetching } = useMarkdownSetting(markdown?.setting_id);
const { data: templateSetting, isFetching: isTemplateSettingFetching } = useMarkdownTemplateSetting(setting?.template_setting_id);
const { data: template, isFetching: isTemplateFetching } = useMarkdownTemplate(templateSetting?.template_id);
// Resolve markdown ID from path using tree
useEffect(() => {
if (tree && pathString) {
const resolvedId = getMarkdownIdByPath(tree, pathString);
setMarkdownId(resolvedId);
}
}, [tree, pathString]);
useEffect(() => {
if (markdown && markdown.title === "index" && pathString) {
const pathParts = pathString.split('/').filter(part => part.length > 0);
if (pathParts.length === 0) {
// Root index: /pg/ or /pg
setIndexTitle("Home");
} else {
// Directory index: /pg/Projects or /pg/Projects/project1
// Use the last directory name as title
const directoryName = pathParts[pathParts.length - 1];
setIndexTitle(directoryName);
}
}
}, [markdown, pathString]);
const notReady = isTreeLoading || isMarkdownLoading || isSettingFetching || isTemplateSettingFetching || isTemplateFetching;
if (notReady) {
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<div>Loading...</div>
</div>
);
}
if (error) {
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<div>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>
);
}
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>
</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>
{markdown && (
<MarkdownView content={JSON.parse(markdown.content)} template={template} />
)}
</div>
);
};
export default StandaloneMarkdownPage;

View File

@@ -0,0 +1,42 @@
import React from 'react';
const JsonSchemaModal = ({ isActive, onClose, schema }) => {
const handleCopy = () => {
navigator.clipboard.writeText(JSON.stringify(schema, null, 2));
};
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>
);
};
export default JsonSchemaModal;

View File

@@ -1,9 +1,30 @@
import {Link} from "react-router-dom"; import {Link, useNavigate} from "react-router-dom";
import PermissionGuard from "../PermissionGuard"; import PermissionGuard from "../PermissionGuard";
import React, {useState} from "react"; import React, {useState} from "react";
import MarkdownSettingModal from "../Modals/MarkdownSettingModal"; import MarkdownSettingModal from "../Modals/MarkdownSettingModal";
import {useDeleteMarkdown} from "../../utils/queries/markdown-queries";
import {useDeleteMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
const MarkdownNode = ({markdown, handleMoveMarkdown}) => { const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
const [isMarkdownSettingModalOpen, setIsMarkdownSettingModalOpen] = useState(false); const [isMarkdownSettingModalOpen, setIsMarkdownSettingModalOpen] = useState(false);
const navigate = useNavigate();
const deleteMarkdown = useDeleteMarkdown();
const deleteMarkdownSetting = useDeleteMarkdownSetting();
const handleDeleteMarkdown = async () => {
if (!window.confirm(`delete markdown "${markdown.title}" ? this action cannot be undone.`)) {
return;
}
try {
await deleteMarkdown.mutateAsync(markdown.id);
if (window.location.pathname === `/markdown/${markdown.id}`) {
navigate('/');
}
} catch (error) {
alert('failed: ' + (error.message || 'unknown error'));
}
};
return ( return (
<li key={markdown.id}> <li key={markdown.id}>
<div className="is-clickable field has-addons"> <div className="is-clickable field has-addons">
@@ -24,6 +45,18 @@ const MarkdownNode = ({markdown, handleMoveMarkdown}) => {
</span> </span>
</button> </button>
</p> </p>
<p className="control">
<button
className="button is-small is-danger"
onClick={handleDeleteMarkdown}
type="button"
disabled={deleteMarkdown.isLoading || deleteMarkdownSetting.isLoading}
>
<span className="icon">
<i className="fas fa-trash"/>
</span>
</button>
</p>
<div <div
className="control is-flex is-flex-direction-column is-align-items-center" className="control is-flex is-flex-direction-column is-align-items-center"
style={{marginLeft: "0.5rem"}} style={{marginLeft: "0.5rem"}}

View File

@@ -2,10 +2,12 @@ import React, { useState } from "react";
import { useMarkdownTemplates } from "../../../utils/queries/markdown-template-queries"; import { useMarkdownTemplates } from "../../../utils/queries/markdown-template-queries";
import PermissionGuard from "../../PermissionGuard"; import PermissionGuard from "../../PermissionGuard";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import JsonSchemaModal from "../../Modals/JsonSchemaModal";
const TemplateTab = () => { const TemplateTab = () => {
const { data: templates, isLoading, error } = useMarkdownTemplates(); const { data: templates, isLoading, error } = useMarkdownTemplates();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [selectedSchema, setSelectedSchema] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const filteredTemplates = templates?.filter(template => const filteredTemplates = templates?.filter(template =>
@@ -16,6 +18,99 @@ const TemplateTab = () => {
navigate(`/template/edit/${templateId}`); navigate(`/template/edit/${templateId}`);
}; };
const generateJsonSchema = (template) => {
const schema = {
type: "object",
properties: {},
$defs: {}
};
const generateTypeSchema = (param, defName) => {
switch (param.type.base_type) {
case "string":
return {
type: "string"
};
case "markdown":
return {
type: "string",
description: "Markdown content"
};
case "enum":
return {
type: "string",
enum: param.type.definition.enums
};
case "list":
if (param.type.extend_type.base_type === "string") {
return {
type: "array",
items: {
type: "string"
}
};
} else if (param.type.extend_type.base_type === "list" ||
param.type.extend_type.base_type === "template") {
const itemsDefName = `${defName}_items`;
schema.$defs[itemsDefName] = generateTypeSchema(
{ type: param.type.extend_type },
itemsDefName
);
return {
type: "array",
items: {
$ref: `#/$defs/${itemsDefName}`
}
};
} else {
return {
type: "array",
items: generateTypeSchema(
{ type: param.type.extend_type },
`${defName}_items`
)
};
}
case "template":
const nestedTemplate = templates.find(t => t.id === param.type.definition.template.id);
if (nestedTemplate) {
const nestedSchema = {
type: "object",
properties: {}
};
nestedTemplate.parameters.forEach(nestedParam => {
nestedSchema.properties[nestedParam.name] = generateTypeSchema(
nestedParam,
`${defName}_${nestedParam.name}`
);
});
return nestedSchema;
} else {
return {
type: "object",
properties: {}
};
}
default:
return {
type: "object",
properties: {}
};
}
};
template.parameters.forEach(param => {
const defName = `param_${param.name}`;
schema.properties[param.name] = generateTypeSchema(param, defName);
});
if (Object.keys(schema.$defs).length === 0) {
delete schema.$defs;
}
return schema;
};
if (isLoading) return <p>Loading...</p>; if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading templates</p>; if (error) return <p>Error loading templates</p>;
@@ -38,23 +133,40 @@ const TemplateTab = () => {
Create New Template Create New Template
</a> </a>
</PermissionGuard> </PermissionGuard>
<ul className="menu-list">
{!filteredTemplates || filteredTemplates.length === 0 ? ( {filteredTemplates?.map((template) => (
<p>No templates found</p> <li key={template.id}>
) : ( <div className="is-flex is-justify-content-space-between is-align-items-center">
<div className="template-list"> <span>{template.title}</span>
{filteredTemplates.map(template => ( <div className="field has-addons is-justify-content-flex-end">
<button <button
key={template.id} className="button is-small control"
className="button is-light is-fullwidth template-button"
onClick={() => handleTemplateClick(template.id)} onClick={() => handleTemplateClick(template.id)}
style={{ marginBottom: "5px", textAlign: "left", justifyContent: "flex-start" }} type="button"
> >
{template.title} <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> </button>
))}
</div> </div>
)} </div>
</li>
))}
</ul>
<JsonSchemaModal
isActive={selectedSchema !== null}
onClose={() => setSelectedSchema(null)}
schema={selectedSchema}
/>
</aside> </aside>
); );
}; };

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

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

View File

@@ -52,6 +52,7 @@ export const useMarkdownsByPath = (pathId) => {
}); });
}; };
export const useSaveMarkdown = () => { export const useSaveMarkdown = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const config = useConfig(); const config = useConfig();
@@ -93,6 +94,24 @@ export const useMoveMarkdown = () => {
}); });
}; };
export const useDeleteMarkdown = () => {
const queryClient = useQueryClient();
const config = useConfig();
return useMutation({
mutationFn: (markdownId) => {
return fetch_(`${config.BACKEND_HOST}/api/markdown/${markdownId}`, {
method: "DELETE"
});
},
onSuccess: (data, markdownId) => {
queryClient.invalidateQueries({queryKey: ["markdown", markdownId]});
queryClient.invalidateQueries({queryKey: ["tree"]});
queryClient.invalidateQueries({queryKey: ["markdownsByPath"]});
}
});
};
export const useSearchMarkdown = (keyword) => { export const useSearchMarkdown = (keyword) => {
const config = useConfig(); const config = useConfig();
return useQuery({ return useQuery({