add: markdown permission setting

improve: template
This commit is contained in:
h z
2025-04-25 00:39:01 +01:00
parent c20cb168ff
commit 9ea44385ee
17 changed files with 893 additions and 372 deletions

View File

@@ -1,4 +1,3 @@
// src/AuthProvider.js
import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
import { UserManager } from "oidc-client-ts"; import { UserManager } from "oidc-client-ts";
import { ConfigContext } from "./ConfigProvider"; import { ConfigContext } from "./ConfigProvider";

View File

@@ -74,7 +74,6 @@ const ParametersManager = ({ parameters, onChange }) => {
updated[index].type = newType; updated[index].type = newType;
setParameters(updated); setParameters(updated);
onChange(updated); onChange(updated);
console.log("updated", updated);
}} }}
/> />
</div> </div>

View File

@@ -1,43 +1,59 @@
import React, {useEffect, useState} from "react"; import React, { useEffect, useState } from "react";
import {useMarkdownTemplates} from "../../utils/queries/markdown-template-queries"; import { useMarkdownTemplates } from "../../utils/queries/markdown-template-queries";
const TemplateSelector = ({template, onChange}) => { const TemplateSelector = ({ template, onChange }) => {
const {data:templates, isFetching: templatesAreFetching} = useMarkdownTemplates(); const { data: templates, isFetching: templatesAreFetching } = useMarkdownTemplates();
const [_template, setTemplate] = useState(templates?.find(t => t.id === template.id) || { const [_template, setTemplate] = useState(
title: "", templates?.find((t) => t.id === template?.id) || {
parameters: [],
layout: ""
});
useEffect(() => {
setTemplate(templates?.find(t => t.id === template.id) || {
title: "", title: "",
parameters: [], parameters: [],
layout: "" layout: "",
}); }
}, [template]); );
if(templatesAreFetching) {
return <p>Loading...</p> useEffect(() => {
setTemplate(
templates?.find((t) => t.id === template?.id) || {
title: "",
parameters: [],
layout: "",
}
);
}, [template, templates]);
if (templatesAreFetching) {
return <p>Loading...</p>;
} }
return ( return (
<select <div className="field">
value={template.id} <label className="label">Select Template</label>
onChange={(e) => { <div className="control">
const templateId = parseInt(e.target.value, 10); <div className="select is-fullwidth is-primary">
onChange(templates.find(t => t.id === templateId) || { <select
title: "", value={template?.id || ""}
parameters: [], onChange={(e) => {
layout: "" const id = parseInt(e.target.value, 10);
}); onChange(
}}> templates.find((t) => t.id === id) || {
<option value="">(None)</option> title: "",
{ parameters: [],
templates.map((tmpl, index) => ( layout: "",
<option key={index} value={tmpl.id} >{tmpl.title}</option> }
)) );
} }}
</select> >
<option value="">(None)</option>
{templates.map((tmpl) => (
<option key={tmpl.id} value={tmpl.id}>
{tmpl.title}
</option>
))}
</select>
</div>
</div>
</div>
); );
}; };
export default TemplateSelector; export default TemplateSelector;

View File

@@ -4,91 +4,116 @@ import TemplateSelector from './TemplateSelector';
const TypeEditor = ({ type, onChange }) => { const TypeEditor = ({ type, onChange }) => {
const [_type, setType] = React.useState(type || {}); const [_type, setType] = React.useState(type || {});
const updateType = (updated) => {
setType(updated);
onChange(updated);
};
const renderExtraFields = () => { const renderExtraFields = () => {
switch (_type.base_type) { switch (_type.base_type) {
case 'enum': case 'enum':
return <EnumsEditor
enums={_type.definition.enums}
onChange={(newEnums) => {
const updated = {
..._type,
definition: {
..._type.definition,
enums: newEnums
}
};
setType(updated);
onChange(updated);
}}
/>;
case 'list':
return ( return (
<div> <div className="field">
<TypeEditor <label className="label">Enums</label>
extendType={_type.extend_type} <div className="control">
onChange={(extendType) => { <EnumsEditor
const updated = {..._type, extend_type: extendType}; enums={_type.definition.enums}
setType(updated); onChange={(newEnums) => {
onChange(updated); updateType({
}} ..._type,
/> definition: { ..._type.definition, enums: newEnums },
<textarea });
className="textarea" }}
value={_type.definition.iter_layout} />
onChange={(e) => { </div>
const updated = {
..._type,
definition: {
..._type.definition,
iter_layout: e.target.value
}
};
setType(updated);
onChange(updated);
}}
/>
</div> </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>
<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': case 'template':
return <TemplateSelector return (
template={_type.definition.template} <div className="field">
onChange={(newTemplate) => { <label className="label">Template</label>
<div className="control">
<TemplateSelector
template={_type.definition.template}
onChange={(newTemplate) => {
updateType({
..._type,
definition: {
..._type.definition,
template: newTemplate,
},
});
}}
/>
</div>
</div>
);
const updated = {
..._type,
definition: {
..._type.definition,
template: newTemplate
}
};
setType(updated);
onChange(updated);
}}
/>;
default: default:
return null; return null;
} }
}; };
return ( return (
<div> <div className="box">
<label>type:</label> <div className="field">
<select value={_type.base_type} onChange={(e) => { <label className="label">Type</label>
const updated = { <div className="control">
base_type: e.target.value, <div className="select is-fullwidth">
definition: {} <select
}; value={_type.base_type || ''}
setType(updated); onChange={(e) => {
onChange(updated); const updated = { base_type: e.target.value, definition: {} };
}}> updateType(updated);
<option value="string">string</option> }}
<option value="markdown">markdown</option> >
<option value="enum">enum</option> <option value="string">string</option>
<option value="list">list</option> <option value="markdown">markdown</option>
<option value="template">template</option> <option value="enum">enum</option>
</select> <option value="list">list</option>
<option value="template">template</option>
</select>
</div>
</div>
</div>
{renderExtraFields()} {renderExtraFields()}
</div> </div>
); );

View File

@@ -36,6 +36,17 @@ const MarkdownContent = () => {
return <div>Error: {error.message || "Failed to load content"}</div>; return <div>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>
);
}
return ( return (
<div className="markdown-content-container"> <div className="markdown-content-container">
<div className="field has-addons markdown-content-container-header"> <div className="field has-addons markdown-content-container-header">

View File

@@ -1,6 +1,6 @@
.markdown-editor-container { .markdown-editor-container {
max-width: 800px; max-width: 90vw;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
background-color: #f8f9fa; background-color: #f8f9fa;
@@ -92,4 +92,19 @@ pre {
padding: 10px; padding: 10px;
border-radius: 6px; border-radius: 6px;
overflow-x: auto; 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

@@ -6,10 +6,11 @@ import "./MarkdownEditor.css";
import PathManager from "../PathManager"; import PathManager from "../PathManager";
import MarkdownView from "./MarkdownView"; import MarkdownView from "./MarkdownView";
import { useMarkdown, useSaveMarkdown } from "../../utils/queries/markdown-queries"; import { useMarkdown, useSaveMarkdown } from "../../utils/queries/markdown-queries";
import {useMarkdownSetting} from "../../utils/queries/markdown-setting-queries"; import {useMarkdownSetting, useUpdateMarkdownSetting} from "../../utils/queries/markdown-setting-queries";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries"; import {useMarkdownTemplate, useMarkdownTemplates} from "../../utils/queries/markdown-template-queries";
import TemplatedEditor from "./TemplatedEditor"; import TemplatedEditor from "./TemplatedEditor";
import {useMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries"; import {useMarkdownTemplateSetting, useUpdateMarkdownTemplateSetting, useCreateMarkdownTemplateSetting} from "../../utils/queries/markdown-template-setting-queries";
import TemplateSelector from "../MarkdownTemplate/TemplateSelector";
const MarkdownEditor = () => { const MarkdownEditor = () => {
const { roles } = useContext(AuthContext); const { roles } = useContext(AuthContext);
@@ -19,23 +20,48 @@ const MarkdownEditor = () => {
const [content, setContent] = useState({}); const [content, setContent] = useState({});
const [shortcut, setShortcut] = useState(""); const [shortcut, setShortcut] = useState("");
const [pathId, setPathId] = useState(1); const [pathId, setPathId] = useState(1);
const [isRawMode, setIsRawMode] = useState(false);
const [rawContent, setRawContent] = useState("");
const [jsonError, setJsonError] = useState("");
const {data: markdown, isLoading, error} = useMarkdown(id); const {data: markdown, isLoading, error} = useMarkdown(id);
const saveMarkdown = useSaveMarkdown(); const saveMarkdown = useSaveMarkdown();
const {data: setting, isFetching: isSettingFetching} = useMarkdownSetting(markdown?.setting_id); const {data: setting, isFetching: isSettingFetching} = useMarkdownSetting(markdown?.setting_id);
const {data: templateSetting, isFetching: isTemplateSettingFetching} = useMarkdownTemplateSetting(setting?.template_setting_id); const {data: templateSetting, isFetching: isTemplateSettingFetching} = useMarkdownTemplateSetting(setting?.template_setting_id);
const {data: template, isFetching: isTemplateFetching} = useMarkdownTemplate(templateSetting?.template_id); const {data: template, isFetching: isTemplateFetching} = useMarkdownTemplate(templateSetting?.template_id);
const updateTemplateSetting = useUpdateMarkdownTemplateSetting();
const createTemplateSetting = useCreateMarkdownTemplateSetting();
const updateSetting = useUpdateMarkdownSetting();
const {data: templates} = useMarkdownTemplates();
const notReady = isLoading || isTemplateFetching || isSettingFetching || isTemplateSettingFetching; const notReady = isLoading || isTemplateFetching || isSettingFetching || isTemplateSettingFetching;
useEffect(() => { useEffect(() => {
if(markdown){ if(markdown){
setTitle(markdown.title); setTitle(markdown.title);
setContent(JSON.parse(markdown.content)); if (markdown.isMessage) {
setShortcut(markdown.shortcut); navigate("/");
setPathId(markdown.path_id); alert(markdown.content || "Cannot edit this markdown");
return;
}
try {
const parsedContent = JSON.parse(markdown.content);
setContent(parsedContent);
setRawContent(JSON.stringify(parsedContent, null, 2));
setShortcut(markdown.shortcut);
setPathId(markdown.path_id);
} catch (e) {
console.error("Error parsing markdown content:", e);
alert("Error parsing markdown content");
navigate("/");
}
} }
}, [markdown]); }, [markdown, navigate]);
const handleSave = () => { const handleSave = () => {
if (isRawMode && jsonError) {
alert("Please fix the JSON errors before saving");
return;
}
saveMarkdown.mutate( saveMarkdown.mutate(
{id, data: {title, content: JSON.stringify(content), path_id: pathId, shortcut}}, {id, data: {title, content: JSON.stringify(content), path_id: pathId, shortcut}},
{ {
@@ -48,6 +74,83 @@ const MarkdownEditor = () => {
}); });
}; };
const toggleEditMode = () => {
if (isRawMode) {
try {
const parsed = JSON.parse(rawContent);
setContent(parsed);
setJsonError("");
setIsRawMode(false);
} catch (e) {
setJsonError("Invalid JSON: " + e.message);
return;
}
} else {
setRawContent(JSON.stringify(content, null, 2));
setIsRawMode(true);
}
};
const handleRawContentChange = (e) => {
const newRawContent = e.target.value;
setRawContent(newRawContent);
try {
const parsed = JSON.parse(newRawContent);
setContent(parsed);
setJsonError("");
} catch (e) {
setJsonError("Invalid JSON: " + e.message);
}
};
const handleTemplateChange = (newTemplate) => {
if (!newTemplate) return;
if (templateSetting) {
updateTemplateSetting.mutate(
{
id: templateSetting.id,
data: { template_id: newTemplate.id }
},
{
onSuccess: () => {
setContent({});
},
onError: () => {
alert("Error updating template");
}
}
);
} else if (setting) {
createTemplateSetting.mutate(
{ template_id: newTemplate.id },
{
onSuccess: (newTemplateSetting) => {
updateSetting.mutate(
{
id: setting.id,
data: { template_setting_id: newTemplateSetting.id }
},
{
onSuccess: () => {
setContent({});
},
onError: () => {
alert("Error updating markdown setting");
}
}
);
},
onError: () => {
alert("Error creating template setting");
}
}
);
} else {
alert("Cannot change template: No markdown setting found");
}
};
const hasPermission = roles.includes("admin") || roles.includes("creator"); const hasPermission = roles.includes("admin") || roles.includes("creator");
if (!hasPermission) if (!hasPermission)
@@ -99,19 +202,55 @@ const MarkdownEditor = () => {
</div> </div>
<div className="field"> <div className="field">
<label className="label">Content</label>
<div className="control"> <div className="control">
<TemplatedEditor <TemplateSelector
style={{height: "70vh"}}
content={content}
template={template} template={template}
onContentChanged={(k, v) => setContent( onChange={handleTemplateChange}
prev => ({...prev, [k]: v})
)}
/> />
</div> </div>
</div> </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
type="button"
className={`button is-small editor-toggle-button ${isRawMode ? 'is-info' : 'is-light'}`}
onClick={toggleEditMode}
>
{isRawMode ? 'Switch to Template Editor' : 'Switch to Raw Editor'}
</button>
</div>
<div className="control">
{isRawMode ? (
<div>
<p className="help mb-2">
Edit the JSON directly. Make sure it's valid JSON before saving.
</p>
<textarea
className={`textarea raw-editor ${jsonError ? 'is-danger' : ''}`}
style={{height: "70vh"}}
value={rawContent}
onChange={handleRawContentChange}
placeholder="Enter JSON content here"
/>
{jsonError && (
<p className="help is-danger json-error">{jsonError}</p>
)}
</div>
) : (
<TemplatedEditor
style={{height: "70vh"}}
content={content}
template={template}
onContentChanged={(k, v) => setContent(
prev => ({...prev, [k]: v})
)}
/>
)}
</div>
</div>
<div className="field"> <div className="field">
<div className="control"> <div className="control">
<button <button
@@ -137,4 +276,3 @@ const MarkdownEditor = () => {
}; };
export default MarkdownEditor; export default MarkdownEditor;

View File

@@ -18,10 +18,10 @@ const Translate = ({variable, value}) => {
if (!variable.type.extend_type) if (!variable.type.extend_type)
return []; return [];
return value.map((item, index) => Translate({ return (value || []).map((item, index) => Translate({
variable: {name: index, type: variable.type.extend_type}, variable: {name: index, type: variable.type.extend_type},
value: item, value: item,
})).map((item) => variable.type.definition.iter_layout.replaceAll('<item/>', item)); })).map((item) => variable.type.definition.iter_layout.replaceAll('<item/>', item)).join("");
} }
if(variable.type.base_type === "template"){ if(variable.type.base_type === "template"){
return ParseTemplate({ return ParseTemplate({

View File

@@ -1,172 +1,177 @@
import React, {useState} from "react"; import React, {useState} from "react";
import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries"; import {useMarkdownTemplate} from "../../utils/queries/markdown-template-queries";
const TemplatedEditorComponent = ({ variable, value={}, namespace, onContentChanged }) => { const TemplatedEditorComponent = ({ variable, value, namespace, onContentChanged }) => {
if(variable.type.base_type === "string") { console.log("variable", variable);
return ( const __namespace = `${variable.name}(${variable.type.base_type})`;
<div>
<label>{namespace}{variable.name} - {variable.type.base_type}</label>
<input
type="text"
className="input"
value={value ?? ""}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
<hr/>
</div>
);
}
if (variable.type.base_type === 'markdown') {
return(
<div>
<label>{namespace}{variable.name} - {variable.type.base_type}</label>
<textarea
className="textarea"
value={value}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
<hr/>
</div>
);
}
if(variable.type.base_type === "enum"){ const renderField = () => {
return ( switch (variable.type.base_type) {
<div> case "string":
<label>{namespace}{variable.name} - {variable.type.base_type}</label> return (
<select <div className="box has-background-danger-soft">
value={value} <label className="label">{__namespace}</label>
onChange={(e) => onContentChanged(variable.name, e.target.value)} <div className="control">
> <input
{variable.type.definition.enums.map((item) => ( type="text"
<option key={item} value={item}>{item}</option> className="input"
))} value={value ?? ""}
</select> onChange={(e) => onContentChanged(variable.name, e.target.value)}
<hr/>
</div>
);
}
if(variable.type.base_type === "list"){
const [cache, setCache] = useState(value);
const defaultValue = variable.type.definition.default || undefined;
const addItem = () => {
setCache(prev => {
const newCache = [...prev, defaultValue];
onContentChanged(variable.name, newCache);
return newCache;
});
};
const removeItem = (index) => {
setCache(prev => {
const newCache = prev.filter((_, i) => i!==index);
onContentChanged(variable.name, newCache);
return newCache;
})
};
const _onContentChanged = (index, value) => {
setCache(prev => {
const newCache = [...prev];
newCache[index] = value;
onContentChanged(variable.name, newCache);
return newCache;
});
};
return (
<div>
<label>{namespace}{variable.name} - {variable.type.base_type}[{variable.type.extend_type.base_type}]</label>
{cache.map((item, index) =>
(<div key={index}>
<TemplatedEditorComponent
variable={{name: index, type: variable.type.extend_type}}
value={item}
namespace={namespace}
onContentChanged={_onContentChanged}
/>
<button
className="button is-danger"
type="button"
onClick={() => removeItem(index)}
>
DELETE
</button>
</div>)
)}
<button className="button is-warning" type="button" onClick={addItem}>
ADD
</button>
<hr/>
</div>
);
}
if(variable.type.base_type === 'template'){
const {data: _template, isFetching: _templateIsFetching} = useMarkdownTemplate(variable.type.definition.template.id);
if(_templateIsFetching){
return(<p>Loading...</p>);
}
const _parameters = _template.parameters;
const _namespace = namespace + _template.title+ "::"
const _onSubChanged = (subKey, subValue) => {
const updated = {
...value,
[subKey]: subValue
};
onContentChanged(variable.name, updated);
};
return (
<div>
<label>{namespace}{variable.name} - {variable.type.base_type}</label>
{
_parameters.map((_variable, index) => (
<div key={index}>
<TemplatedEditorComponent
variable={_variable}
value={value[_variable.name]}
namespace={_namespace}
onContentChanged={_onSubChanged}
/> />
</div> </div>
)) </div>
} );
<hr/>
</div> case "markdown":
); return (
} <div className="box has-background-primary-soft">
<label className="label">{__namespace}</label>
<div className="control">
<textarea
className="textarea"
value={value}
onChange={(e) => onContentChanged(variable.name, e.target.value)}
/>
</div>
</div>
);
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>
);
case "list": {
const [cache, setCache] = useState(value || []);
const defaultValue = variable.type.definition.default;
const addItem = () => {
const newCache = [...cache, defaultValue];
setCache(newCache);
onContentChanged(variable.name, newCache);
};
const removeItem = (index) => {
const newCache = cache.filter((_, i) => i !== index);
setCache(newCache);
onContentChanged(variable.name, newCache);
};
const onItemChange = (index, val) => {
const newCache = [...cache];
newCache[index] = val;
setCache(newCache);
onContentChanged(variable.name, newCache);
};
return (
<div className="box has-background-white-soft">
<label className="label">{__namespace}</label>
{cache.map((item, idx) => (
<div className="field is-grouped" key={idx}>
<div className="control is-expanded">
<TemplatedEditorComponent
variable={{ name: idx, type: variable.type.extend_type }}
value={item}
namespace={__namespace}
onContentChanged={(subKey, subVal) => onItemChange(idx, subVal)}
/>
</div>
<div className="control">
<button
className="button is-danger"
type="button"
onClick={() => removeItem(idx)}
>
DELETE
</button>
</div>
</div>
))}
<div className="field">
<div className="control">
<button
className="button is-warning"
type="button"
onClick={addItem}
>
ADD
</button>
</div>
</div>
</div>
);
}
case "template": {
const { data: _template, isFetching: loading } = useMarkdownTemplate(variable.type.definition.template.id);
if (loading) return <p>Loading...</p>;
const _parameters = _template.parameters;
const handleSubChange = (key, val) => {
const updated = { ...(value || {}), [key]: val };
onContentChanged(variable.name, updated);
};
return (
<div className="box has-background-grey-light">
<label className="label">{__namespace}</label>
{_parameters.map((param, i) => (
<div className="field" key={i}>
<TemplatedEditorComponent
variable={param}
value={(value || {})[param.name]}
namespace={__namespace}
onContentChanged={handleSubChange}
/>
</div>
))}
</div>
);
}
default:
return null;
}
};
return <>{renderField()}</>;
}; };
const TemplatedEditor = ({content, template, onContentChanged}) => { const TemplatedEditor = ({ content, template, onContentChanged }) => {
if(!template){ const tpl = template || {
template = { parameters: [{ name: "markdown", type: { base_type: "markdown", definition: {} } }],
parameters: [ layout: "<markdown/>",
{ title: "default",
name: "markdown", };
type: {
base_type: "markdown", return (
definition: {} <div className="box">
} {tpl.parameters.map((variable, idx) => (
} <TemplatedEditorComponent
], key={idx}
layout: "<markdown/>", variable={variable}
title: "default" value={content[variable.name]}
}; namespace={tpl.title}
} onContentChanged={onContentChanged}
return( />
<div> ))}
{
template.parameters.map((variable, index) => (
<div key={index}>
<TemplatedEditorComponent
variable={variable}
value={content[variable.name]}
namespace={template.title+"::"}
onContentChanged={onContentChanged}
/>
</div>
))
}
</div> </div>
); );
}; };
export default TemplatedEditor; export default TemplatedEditor;

View File

@@ -2,6 +2,7 @@ import {useCreateMarkdownSetting, useMarkdownSetting} from "../../utils/queries/
import {useSaveMarkdown} from "../../utils/queries/markdown-queries"; import {useSaveMarkdown} from "../../utils/queries/markdown-queries";
import React, {useState} from "react"; import React, {useState} from "react";
import MarkdownTemplateSettingPanel from "../Settings/MarkdownSettings/MarkdownTemplateSettingPanel"; import MarkdownTemplateSettingPanel from "../Settings/MarkdownSettings/MarkdownTemplateSettingPanel";
import MarkdownPermissionSettingPanel from "../Settings/MarkdownSettings/MarkdownPermissionSettingPanel";
const MarkdownSettingModal = ({isOpen, markdown, onClose}) => { const MarkdownSettingModal = ({isOpen, markdown, onClose}) => {
const {data: markdownSetting, isFetching: markdownSettingIsFetching} = useMarkdownSetting(markdown?.setting_id || 0); const {data: markdownSetting, isFetching: markdownSettingIsFetching} = useMarkdownSetting(markdown?.setting_id || 0);
@@ -47,6 +48,9 @@ const MarkdownSettingModal = ({isOpen, markdown, onClose}) => {
<li className={activeTab==="template" ? "is-active" : ""}> <li className={activeTab==="template" ? "is-active" : ""}>
<a onClick={() => setActiveTab("template")}>Template</a> <a onClick={() => setActiveTab("template")}>Template</a>
</li> </li>
<li className={activeTab==="permission" ? "is-active" : ""}>
<a onClick={() => setActiveTab("permission")}>Permission</a>
</li>
</ul> </ul>
</div> </div>
{activeTab === "template" && ( {activeTab === "template" && (
@@ -55,6 +59,12 @@ const MarkdownSettingModal = ({isOpen, markdown, onClose}) => {
onClose={onClose} onClose={onClose}
/> />
)} )}
{activeTab === "permission" && (
<MarkdownPermissionSettingPanel
markdownSetting={markdownSetting}
onClose={onClose}
/>
)}
</section> </section>
) : ( ) : (
<section className="modal-card-body"> <section className="modal-card-body">

View File

@@ -5,7 +5,7 @@
background-color: #f9f9f9; background-color: #f9f9f9;
height: 90vh; height: 90vh;
overflow-y: hidden; overflow-y: hidden;
min-width: 25vw; min-width: 15vw;
} }
.menu-label { .menu-label {
@@ -58,4 +58,25 @@
} }
.markdown-node { .markdown-node {
background-color: #fffbe6 !important; background-color: #fffbe6 !important;
} }
.tabs ul {
display: flex;
margin-bottom: 0;
padding-left: 0;
}
.tabs li {
flex: 1;
list-style: none;
}
.side-nav {
min-width: 15vw;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -1,95 +1,50 @@
import PermissionGuard from "../PermissionGuard"; import React, { useContext, useEffect } from 'react';
import PathNode from "./PathNode";
import "./SideNavigation.css"; import "./SideNavigation.css";
import {useDeletePath, usePaths, useUpdatePath} from "../../utils/queries/path-queries"; import TreeTab from "./SideTabs/TreeTab";
import React from 'react'; import TemplateTab from "./SideTabs/TemplateTab";
import {useTree} from "../../utils/queries/tree-queries"; import { AuthContext } from "../../AuthProvider";
const SideNavigation = () => { const SideNavigation = () => {
const {data: tree, isLoading, error} = useTree(); const { roles } = useContext(AuthContext);
const deletePath = useDeletePath(); const [selectedTab, setSelectedTab] = React.useState("tree");
const updatePath = useUpdatePath();
const [keyword, setKeyword] = React.useState(""); const allTabs = [
const handleDelete = (id) => { { id: "tree", label: "Tree", component: <TreeTab /> },
if (window.confirm("Are you sure you want to delete this path?")){ { id: "templates", label: "Templates", component: <TemplateTab /> },
deletePath.mutate(id, { ];
onError: (err) => {
alert("Failed to delete path"); const visibleTabs = roles.includes("admin")
}, ? allTabs
}); : allTabs.filter(tab => tab.id === "tree");
useEffect(() => {
if (!visibleTabs.find(tab => tab.id === selectedTab)) {
setSelectedTab(visibleTabs[0]?.id || "");
} }
}; }, [visibleTabs, selectedTab]);
const filterTree = (t, k) => { const current = visibleTabs.find(t => t.id === selectedTab);
if(t === undefined)
return undefined;
if (t.type === "path") {
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 };
}
}
return undefined;
};
const filteredTree = filterTree(tree, keyword);
const handleSave = (id, newName) => {
updatePath.mutate({ id, data: {name: newName }} , {
onError: (err) => {
alert("Failed to update path");
}
});
};
if (isLoading) return <aside className="menu"><p>Loading...</p></aside>;
if (error) return <aside className="menu"><p>Error loading tree</p></aside>;
return ( return (
<aside className="menu"> <aside className="side-nav">
<div className="control is-expanded"> <div className="tabs is-small">
<input <ul>
className="input is-small" {visibleTabs.map(tab => (
type="text" <li
placeholder="Search..." key={tab.id}
onChange={(e) => setKeyword(e.target.value)} className={tab.id === selectedTab ? "is-active" : ""}
/> >
<a onClick={() => setSelectedTab(tab.id)}>
{tab.label}
</a>
</li>
))}
</ul>
</div>
<div className="tab-content">
{current?.component}
</div> </div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<a
href="/markdown/create"
className="button is-primary is-small"
>
Create New Markdown
</a>
<a
href="/template/create"
className="button is-primary is-small"
>
Create New Template
</a>
</PermissionGuard>
{!filteredTree || filteredTree.length === 0 ?
<p>No Result</p> :
<PathNode
key={1}
path={filteredTree}
isRoot={true}
onSave={handleSave}
onDelete={handleDelete}
/>
}
</aside> </aside>
); );
}; };

View File

@@ -0,0 +1,62 @@
import React, { useState } from "react";
import { useMarkdownTemplates } from "../../../utils/queries/markdown-template-queries";
import PermissionGuard from "../../PermissionGuard";
import { useNavigate } from "react-router-dom";
const TemplateTab = () => {
const { data: templates, isLoading, error } = useMarkdownTemplates();
const [keyword, setKeyword] = useState("");
const navigate = useNavigate();
const filteredTemplates = templates?.filter(template =>
template.title.toLowerCase().includes(keyword.toLowerCase())
);
const handleTemplateClick = (templateId) => {
navigate(`/template/edit/${templateId}`);
};
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading templates</p>;
return (
<aside className="menu">
<div className="control is-expanded">
<input
className="input is-small"
type="text"
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>
</PermissionGuard>
{!filteredTemplates || filteredTemplates.length === 0 ? (
<p>No templates found</p>
) : (
<div className="template-list">
{filteredTemplates.map(template => (
<button
key={template.id}
className="button is-light is-fullwidth template-button"
onClick={() => handleTemplateClick(template.id)}
style={{ marginBottom: "5px", textAlign: "left", justifyContent: "flex-start" }}
>
{template.title}
</button>
))}
</div>
)}
</aside>
);
};
export default TemplateTab;

View File

@@ -0,0 +1,88 @@
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";
const TreeTab = () => {
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?")){
deletePath.mutate(id, {
onError: (err) => {
alert("Failed to delete path");
},
});
}
};
const filterTree = (t, k) => {
if(t === undefined)
return undefined;
if (t.type === "path") {
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 };
}
}
return undefined;
};
const filteredTree = filterTree(tree, keyword);
const handleSave = (id, newName) => {
updatePath.mutate({ id, data: {name: newName }} , {
onError: (err) => {
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"
type="text"
placeholder="Search..."
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
<PermissionGuard rolesRequired={["admin", "creator"]}>
<a
href="/markdown/create"
className="button is-primary is-small"
>
Create New Markdown
</a>
</PermissionGuard>
{!filteredTree || filteredTree.length === 0 ?
<p>No Result</p> :
<PathNode
key={1}
path={filteredTree}
isRoot={true}
onSave={handleSave}
onDelete={handleDelete}
/>
}
</aside>
);
};
export default TreeTab;

View File

@@ -0,0 +1,91 @@
import {
useCreateMarkdownPermissionSetting,
useMarkdownPermissionSetting,
useUpdateMarkdownPermissionSetting
} from "../../../utils/queries/markdown-permission-setting-queries";
import React, {useEffect, useState} from "react";
import {useUpdateMarkdownSetting} from "../../../utils/queries/markdown-setting-queries";
const MarkdownPermissionSettingPanel = ({markdownSetting, onClose}) => {
const {data: setting, isFetching: settingIsFetching } = useMarkdownPermissionSetting(markdownSetting?.permission_setting_id);
const [permission, setPermission] = useState("");
const createMarkdownPermissionSetting = useCreateMarkdownPermissionSetting();
const updateMarkdownSetting = useUpdateMarkdownSetting();
const updateMarkdownPermissionSetting = useUpdateMarkdownPermissionSetting();
useEffect(() => {
if (setting && setting.permission !== undefined && setting.permission !== null) {
setPermission(setting.permission);
}
}, [setting]);
const handleCreatePermissionSetting = () => {
createMarkdownPermissionSetting.mutate({permission: null}, {
onSuccess: (data) => {
updateMarkdownSetting.mutate({
id: markdownSetting.id,
data: {
permission_setting_id: data.id,
}
});
}
});
};
const handleSaveMarkdownPermissionSetting = () => {
const permissionValue = permission === "" ? null : permission;
updateMarkdownPermissionSetting.mutate({
id: setting.id,
data: {
permission: permissionValue,
}
}, {
onSuccess: () => alert("Saved"),
onError: () => alert("Failed to save"),
});
onClose();
};
if (settingIsFetching) {
return (<p>Loading...</p>);
}
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>
<button
className="button is-primary"
type="button"
onClick={handleSaveMarkdownPermissionSetting}
>
Save Permission Setting
</button>
</div>
) : (
<button
className="button is-primary"
type="button"
onClick={handleCreatePermissionSetting}
>
Create Permission Setting
</button>
);
};
export default MarkdownPermissionSettingPanel;

View File

@@ -0,0 +1,75 @@
import {useConfig} from "../../ConfigProvider";
import {useMutation, useQuery, useQueryClient} from "react-query";
import {fetch_} from "../request-utils";
export const useMarkdownPermissionSettings = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useQuery(
"markdown_permission_settings",
() => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/`), {
onSuccess: (data) => {
if(data){
for(const setting of data){
queryClient.invalidateQueries(["markdown_permission_setting", setting.id]);
}
}
}
}
);
};
export const useMarkdownPermissionSetting = (setting_id) => {
const config = useConfig();
return useQuery(
["markdown_permission_setting", setting_id],
() => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/${setting_id}/`), {
enabled: !!setting_id,
}
);
};
export const useCreateMarkdownPermissionSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation((data) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/`, {
method: "POST",
body: JSON.stringify(data),
}), {
onSuccess: (data) => {
queryClient.invalidateQueries(["markdown_permission_setting", data.id]);
queryClient.invalidateQueries("markdown_permission_settings");
}
});
};
export const useUpdateMarkdownPermissionSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation(
({id, data}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),{
onSuccess: (res) => {
queryClient.invalidateQueries(["markdown_permission_setting", res.id]);
queryClient.invalidateQueries("markdown_permission_settings");
}
}
);
};
export const useDeleteMarkdownPermissionSetting = () => {
const config = useConfig();
const queryClient = useQueryClient();
return useMutation(
({id}) => fetch_(`${config.BACKEND_HOST}/api/setting/markdown/permission/${id}`, {
method: "DELETE",
}), {
onSuccess: (res, variables) => {
queryClient.invalidateQueries(["markdown_permission_setting", variables.id]);
queryClient.invalidateQueries("markdown_permission_settings");
}
}
);
};

View File

@@ -24,5 +24,16 @@ export async function fetch_(url, init = {}) {
return null; return null;
} }
if (response.status === 203) {
const data = await response.json();
return {
id: null,
content: data.msg || "Non-authoritative information received",
title: "Message",
isMessage: true,
...data
};
}
return response.json(); return response.json();
} }