@@ -6,6 +6,7 @@ import { HelmetProvider } from "react-helmet-async";
|
|||||||
import { RelayEnvironmentProvider } from "react-relay";
|
import { RelayEnvironmentProvider } from "react-relay";
|
||||||
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
|
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
import "./styles/policy-content.css";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import ConsoleLayout from "./layouts/ConsoleLayout";
|
import ConsoleLayout from "./layouts/ConsoleLayout";
|
||||||
import AuthLayout from "./layouts/AuthLayout";
|
import AuthLayout from "./layouts/AuthLayout";
|
||||||
|
|||||||
230
apps/console/src/components/PolicyEditor.css
Normal file
230
apps/console/src/components/PolicyEditor.css
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
.policy-editor-container {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor {
|
||||||
|
background: #fff;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor-inner {
|
||||||
|
background: #fff;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0 0 0.5rem 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor-input {
|
||||||
|
min-height: 250px;
|
||||||
|
max-height: 500px;
|
||||||
|
resize: none;
|
||||||
|
font-size: 15px;
|
||||||
|
position: relative;
|
||||||
|
tab-size: 1;
|
||||||
|
outline: 0;
|
||||||
|
padding: 15px 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor-placeholder {
|
||||||
|
color: #999;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
top: 15px;
|
||||||
|
left: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
user-select: none;
|
||||||
|
display: inline-block;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor-paragraph {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor .toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor .toolbar button {
|
||||||
|
border: 0;
|
||||||
|
display: flex;
|
||||||
|
background: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor .toolbar button:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor .toolbar button.active {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-editor .toolbar .divider {
|
||||||
|
width: 1px;
|
||||||
|
background-color: hsl(var(--border));
|
||||||
|
margin: 0 8px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
.editor-heading-h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-heading-h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-heading-h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-heading-h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-heading-h5 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-quote {
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 20px;
|
||||||
|
padding-left: 16px;
|
||||||
|
border-left: 4px solid hsl(var(--border));
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-list-ol {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-list-ul {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-listitem {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-nested-listitem {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text formatting */
|
||||||
|
.editor-text-bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-text-italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-text-underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-text-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-text-underlineStrikethrough {
|
||||||
|
text-decoration: underline line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-text-code {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-link {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-code {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
font-family: monospace;
|
||||||
|
display: block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code highlight */
|
||||||
|
.editor-tokenComment {
|
||||||
|
color: slategray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tokenPunctuation {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tokenProperty {
|
||||||
|
color: #905;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tokenSelector {
|
||||||
|
color: #690;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tokenOperator {
|
||||||
|
color: #9a6e3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tokenAttr {
|
||||||
|
color: #07a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tokenVariable {
|
||||||
|
color: #e90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-tokenFunction {
|
||||||
|
color: #dd4a68;
|
||||||
|
}
|
||||||
381
apps/console/src/components/PolicyEditor.tsx
Normal file
381
apps/console/src/components/PolicyEditor.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||||
|
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
||||||
|
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||||
|
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
|
||||||
|
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
|
||||||
|
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
|
||||||
|
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
|
||||||
|
import { ListItemNode, ListNode } from "@lexical/list";
|
||||||
|
import { CodeHighlightNode, CodeNode } from "@lexical/code";
|
||||||
|
import { AutoLinkNode, LinkNode } from "@lexical/link";
|
||||||
|
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
|
||||||
|
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
|
||||||
|
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
|
||||||
|
import { TRANSFORMERS } from "@lexical/markdown";
|
||||||
|
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
|
||||||
|
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||||
|
import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
|
||||||
|
import { $getRoot, $insertNodes, EditorState, LexicalEditor } from "lexical";
|
||||||
|
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { $isRangeSelection, FORMAT_TEXT_COMMAND, $getSelection } from "lexical";
|
||||||
|
import { $createHeadingNode, $isHeadingNode } from "@lexical/rich-text";
|
||||||
|
import { $setBlocksType } from "@lexical/selection";
|
||||||
|
import { $createParagraphNode } from "lexical";
|
||||||
|
import {
|
||||||
|
INSERT_ORDERED_LIST_COMMAND,
|
||||||
|
INSERT_UNORDERED_LIST_COMMAND,
|
||||||
|
} from "@lexical/list";
|
||||||
|
|
||||||
|
import "./PolicyEditor.css";
|
||||||
|
|
||||||
|
// Theme for the editor
|
||||||
|
const theme = {
|
||||||
|
ltr: "ltr",
|
||||||
|
rtl: "rtl",
|
||||||
|
paragraph: "editor-paragraph",
|
||||||
|
quote: "editor-quote",
|
||||||
|
heading: {
|
||||||
|
h1: "editor-heading-h1",
|
||||||
|
h2: "editor-heading-h2",
|
||||||
|
h3: "editor-heading-h3",
|
||||||
|
h4: "editor-heading-h4",
|
||||||
|
h5: "editor-heading-h5",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
nested: {
|
||||||
|
listitem: "editor-nested-listitem",
|
||||||
|
},
|
||||||
|
ol: "editor-list-ol",
|
||||||
|
ul: "editor-list-ul",
|
||||||
|
listitem: "editor-listitem",
|
||||||
|
},
|
||||||
|
image: "editor-image",
|
||||||
|
link: "editor-link",
|
||||||
|
text: {
|
||||||
|
bold: "editor-text-bold",
|
||||||
|
italic: "editor-text-italic",
|
||||||
|
underline: "editor-text-underline",
|
||||||
|
strikethrough: "editor-text-strikethrough",
|
||||||
|
underlineStrikethrough: "editor-text-underlineStrikethrough",
|
||||||
|
code: "editor-text-code",
|
||||||
|
},
|
||||||
|
code: "editor-code",
|
||||||
|
codeHighlight: {
|
||||||
|
atrule: "editor-tokenAttr",
|
||||||
|
attr: "editor-tokenAttr",
|
||||||
|
boolean: "editor-tokenProperty",
|
||||||
|
builtin: "editor-tokenSelector",
|
||||||
|
cdata: "editor-tokenComment",
|
||||||
|
char: "editor-tokenSelector",
|
||||||
|
class: "editor-tokenFunction",
|
||||||
|
"class-name": "editor-tokenFunction",
|
||||||
|
comment: "editor-tokenComment",
|
||||||
|
constant: "editor-tokenProperty",
|
||||||
|
deleted: "editor-tokenProperty",
|
||||||
|
doctype: "editor-tokenComment",
|
||||||
|
entity: "editor-tokenOperator",
|
||||||
|
function: "editor-tokenFunction",
|
||||||
|
important: "editor-tokenVariable",
|
||||||
|
inserted: "editor-tokenSelector",
|
||||||
|
keyword: "editor-tokenAttr",
|
||||||
|
namespace: "editor-tokenVariable",
|
||||||
|
number: "editor-tokenProperty",
|
||||||
|
operator: "editor-tokenOperator",
|
||||||
|
prolog: "editor-tokenComment",
|
||||||
|
property: "editor-tokenProperty",
|
||||||
|
punctuation: "editor-tokenPunctuation",
|
||||||
|
regex: "editor-tokenVariable",
|
||||||
|
selector: "editor-tokenSelector",
|
||||||
|
string: "editor-tokenSelector",
|
||||||
|
symbol: "editor-tokenProperty",
|
||||||
|
tag: "editor-tokenProperty",
|
||||||
|
url: "editor-tokenOperator",
|
||||||
|
variable: "editor-tokenVariable",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple Toolbar Plugin
|
||||||
|
function ToolbarPlugin() {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [isBold, setIsBold] = useState(false);
|
||||||
|
const [isItalic, setIsItalic] = useState(false);
|
||||||
|
const [isUnderline, setIsUnderline] = useState(false);
|
||||||
|
const [blockType, setBlockType] = useState("paragraph");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerUpdateListener(({ editorState }) => {
|
||||||
|
editorState.read(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
setIsBold(selection.hasFormat("bold"));
|
||||||
|
setIsItalic(selection.hasFormat("italic"));
|
||||||
|
setIsUnderline(selection.hasFormat("underline"));
|
||||||
|
|
||||||
|
const anchorNode = selection.anchor.getNode();
|
||||||
|
const element = anchorNode.getTopLevelElementOrThrow();
|
||||||
|
if ($isHeadingNode(element)) {
|
||||||
|
setBlockType(element.getTag());
|
||||||
|
} else {
|
||||||
|
setBlockType("paragraph");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const formatHeading = (headingSize: "h1" | "h2") => {
|
||||||
|
if (blockType !== headingSize) {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$setBlocksType(selection, () => $createHeadingNode(headingSize));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$setBlocksType(selection, () => $createParagraphNode());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBulletList = () => {
|
||||||
|
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumberedList = () => {
|
||||||
|
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toolbar">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
|
||||||
|
}}
|
||||||
|
className={isBold ? "active" : ""}
|
||||||
|
title="Bold"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format text as bold"
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
|
||||||
|
}}
|
||||||
|
className={isItalic ? "active" : ""}
|
||||||
|
title="Italic"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format text as italics"
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
|
||||||
|
}}
|
||||||
|
className={isUnderline ? "active" : ""}
|
||||||
|
title="Underline"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format text to underlined"
|
||||||
|
>
|
||||||
|
<Underline className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => formatHeading("h1")}
|
||||||
|
className={blockType === "h1" ? "active" : ""}
|
||||||
|
title="Heading 1"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as heading 1"
|
||||||
|
>
|
||||||
|
<Heading1 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => formatHeading("h2")}
|
||||||
|
className={blockType === "h2" ? "active" : ""}
|
||||||
|
title="Heading 2"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as heading 2"
|
||||||
|
>
|
||||||
|
<Heading2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={formatBulletList}
|
||||||
|
title="Bullet List"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as bullet list"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={formatNumberedList}
|
||||||
|
title="Numbered List"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as numbered list"
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import HTML Plugin
|
||||||
|
function HtmlImportPlugin({
|
||||||
|
initialHtml,
|
||||||
|
setEditor,
|
||||||
|
}: {
|
||||||
|
initialHtml: string;
|
||||||
|
setEditor: (editor: LexicalEditor) => void;
|
||||||
|
}) {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(
|
||||||
|
"HtmlImportPlugin effect running, editor:",
|
||||||
|
!!editor,
|
||||||
|
"initialHtml:",
|
||||||
|
initialHtml
|
||||||
|
? initialHtml.substring(0, 50) + (initialHtml.length > 50 ? "..." : "")
|
||||||
|
: "empty"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (editor) {
|
||||||
|
setEditor(editor);
|
||||||
|
|
||||||
|
editor.update(() => {
|
||||||
|
try {
|
||||||
|
if (initialHtml && initialHtml.trim() !== "") {
|
||||||
|
console.log("Importing HTML content into editor");
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const dom = parser.parseFromString(initialHtml, "text/html");
|
||||||
|
const nodes = $generateNodesFromDOM(editor, dom);
|
||||||
|
|
||||||
|
const root = $getRoot();
|
||||||
|
root.clear();
|
||||||
|
$insertNodes(nodes);
|
||||||
|
console.log("HTML content imported successfully");
|
||||||
|
} else {
|
||||||
|
// If no initial content, ensure we have at least a paragraph
|
||||||
|
const root = $getRoot();
|
||||||
|
if (root.getChildrenSize() === 0) {
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
root.append(paragraph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error importing HTML content:", error);
|
||||||
|
// Ensure we have at least a paragraph on error
|
||||||
|
const root = $getRoot();
|
||||||
|
if (root.getChildrenSize() === 0) {
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
root.append(paragraph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editor, initialHtml, setEditor]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyEditorProps {
|
||||||
|
initialContent: string;
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PolicyEditor({
|
||||||
|
initialContent,
|
||||||
|
onChange,
|
||||||
|
}: PolicyEditorProps) {
|
||||||
|
const [editor, setEditor] = useState<LexicalEditor | null>(null);
|
||||||
|
|
||||||
|
console.log("PolicyEditor rendering with initialContent:", initialContent);
|
||||||
|
|
||||||
|
// Function to handle editor changes
|
||||||
|
const handleEditorChange = (editorState: EditorState) => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
editorState.read(() => {
|
||||||
|
const htmlString = $generateHtmlFromNodes(editor);
|
||||||
|
console.log(
|
||||||
|
"Editor content changed:",
|
||||||
|
htmlString.substring(0, 100) + (htmlString.length > 100 ? "..." : "")
|
||||||
|
);
|
||||||
|
onChange(htmlString);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lexical Editor Configuration
|
||||||
|
const initialConfig = {
|
||||||
|
namespace: "PolicyEditor",
|
||||||
|
theme,
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Lexical Editor Error:", error);
|
||||||
|
},
|
||||||
|
nodes: [
|
||||||
|
HeadingNode,
|
||||||
|
QuoteNode,
|
||||||
|
ListNode,
|
||||||
|
ListItemNode,
|
||||||
|
CodeNode,
|
||||||
|
CodeHighlightNode,
|
||||||
|
TableNode,
|
||||||
|
TableCellNode,
|
||||||
|
TableRowNode,
|
||||||
|
AutoLinkNode,
|
||||||
|
LinkNode,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="policy-editor-container">
|
||||||
|
<LexicalComposer initialConfig={initialConfig}>
|
||||||
|
<div className="policy-editor">
|
||||||
|
<ToolbarPlugin />
|
||||||
|
<div className="policy-editor-inner">
|
||||||
|
<RichTextPlugin
|
||||||
|
contentEditable={
|
||||||
|
<ContentEditable className="policy-editor-input" />
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
<div className="policy-editor-placeholder">
|
||||||
|
Enter policy content...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
|
/>
|
||||||
|
<HistoryPlugin />
|
||||||
|
<AutoFocusPlugin />
|
||||||
|
<ListPlugin />
|
||||||
|
<LinkPlugin />
|
||||||
|
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
|
||||||
|
<OnChangePlugin onChange={handleEditorChange} />
|
||||||
|
<HtmlImportPlugin
|
||||||
|
initialHtml={initialContent}
|
||||||
|
setEditor={setEditor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LexicalComposer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
412
apps/console/src/components/editor/ToolbarPlugin.tsx
Normal file
412
apps/console/src/components/editor/ToolbarPlugin.tsx
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||||
|
import {
|
||||||
|
$createParagraphNode,
|
||||||
|
$getSelection,
|
||||||
|
$isRangeSelection,
|
||||||
|
FORMAT_TEXT_COMMAND,
|
||||||
|
FORMAT_ELEMENT_COMMAND,
|
||||||
|
UNDO_COMMAND,
|
||||||
|
REDO_COMMAND,
|
||||||
|
} from "lexical";
|
||||||
|
import {
|
||||||
|
$createHeadingNode,
|
||||||
|
$createQuoteNode,
|
||||||
|
$isHeadingNode,
|
||||||
|
} from "@lexical/rich-text";
|
||||||
|
import {
|
||||||
|
$createListItemNode,
|
||||||
|
$createListNode,
|
||||||
|
$isListNode,
|
||||||
|
INSERT_ORDERED_LIST_COMMAND,
|
||||||
|
INSERT_UNORDERED_LIST_COMMAND,
|
||||||
|
ListNode,
|
||||||
|
REMOVE_LIST_COMMAND,
|
||||||
|
} from "@lexical/list";
|
||||||
|
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
|
||||||
|
import {
|
||||||
|
$getSelectionStyleValueForProperty,
|
||||||
|
$isParentElementRTL,
|
||||||
|
$patchStyleText,
|
||||||
|
$setBlocksType,
|
||||||
|
} from "@lexical/selection";
|
||||||
|
import { $getNearestNodeOfType } from "@lexical/utils";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
Strikethrough,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Link,
|
||||||
|
Quote,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
AlignLeft,
|
||||||
|
AlignCenter,
|
||||||
|
AlignRight,
|
||||||
|
AlignJustify,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export function ToolbarPlugin() {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [activeEditor, setActiveEditor] = useState(editor);
|
||||||
|
const [isBold, setIsBold] = useState(false);
|
||||||
|
const [isItalic, setIsItalic] = useState(false);
|
||||||
|
const [isUnderline, setIsUnderline] = useState(false);
|
||||||
|
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
||||||
|
const [isLink, setIsLink] = useState(false);
|
||||||
|
const [blockType, setBlockType] = useState("paragraph");
|
||||||
|
const [selectedElementKey, setSelectedElementKey] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [linkUrl, setLinkUrl] = useState("");
|
||||||
|
const [isLinkEditMode, setIsLinkEditMode] = useState(false);
|
||||||
|
const [textAlignment, setTextAlignment] = useState<
|
||||||
|
"left" | "center" | "right" | "justify"
|
||||||
|
>("left");
|
||||||
|
|
||||||
|
const updateToolbar = useCallback(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
setIsBold(selection.hasFormat("bold"));
|
||||||
|
setIsItalic(selection.hasFormat("italic"));
|
||||||
|
setIsUnderline(selection.hasFormat("underline"));
|
||||||
|
setIsStrikethrough(selection.hasFormat("strikethrough"));
|
||||||
|
|
||||||
|
// Check for links
|
||||||
|
const node = getSelectedNode(selection);
|
||||||
|
const parent = node.getParent();
|
||||||
|
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||||
|
setIsLink(true);
|
||||||
|
} else {
|
||||||
|
setIsLink(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get text alignment
|
||||||
|
const alignment = $getSelectionStyleValueForProperty(
|
||||||
|
selection,
|
||||||
|
"text-align",
|
||||||
|
"left"
|
||||||
|
) as "left" | "center" | "right" | "justify";
|
||||||
|
setTextAlignment(alignment);
|
||||||
|
|
||||||
|
// Update block type
|
||||||
|
const anchorNode = selection.anchor.getNode();
|
||||||
|
const element =
|
||||||
|
anchorNode.getKey() === "root"
|
||||||
|
? anchorNode
|
||||||
|
: anchorNode.getTopLevelElementOrThrow();
|
||||||
|
const elementKey = element.getKey();
|
||||||
|
const elementDOM = activeEditor.getElementByKey(elementKey);
|
||||||
|
|
||||||
|
setSelectedElementKey(elementKey);
|
||||||
|
|
||||||
|
if (elementDOM !== null) {
|
||||||
|
if ($isHeadingNode(element)) {
|
||||||
|
const tag = element.getTag();
|
||||||
|
setBlockType(tag);
|
||||||
|
} else if ($isListNode(element)) {
|
||||||
|
const parentList = $getNearestNodeOfType<ListNode>(
|
||||||
|
anchorNode,
|
||||||
|
ListNode
|
||||||
|
);
|
||||||
|
const listType = parentList
|
||||||
|
? parentList.getListType()
|
||||||
|
: element.getListType();
|
||||||
|
setBlockType(listType);
|
||||||
|
} else {
|
||||||
|
setBlockType("paragraph");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeEditor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerUpdateListener(({ editorState }) => {
|
||||||
|
editorState.read(() => {
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [editor, updateToolbar]);
|
||||||
|
|
||||||
|
const formatHeading = (headingSize: "h1" | "h2" | "h3") => {
|
||||||
|
if (blockType !== headingSize) {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$setBlocksType(selection, () => $createHeadingNode(headingSize));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$setBlocksType(selection, () => $createParagraphNode());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatQuote = () => {
|
||||||
|
if (blockType !== "quote") {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$setBlocksType(selection, () => $createQuoteNode());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$setBlocksType(selection, () => $createParagraphNode());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBulletList = () => {
|
||||||
|
if (blockType !== "ul") {
|
||||||
|
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
||||||
|
} else {
|
||||||
|
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumberedList = () => {
|
||||||
|
if (blockType !== "ol") {
|
||||||
|
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
||||||
|
} else {
|
||||||
|
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLink = () => {
|
||||||
|
if (!isLink) {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
const url = prompt("Enter URL", "https://");
|
||||||
|
if (url) {
|
||||||
|
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTextAlignment = (
|
||||||
|
alignment: "left" | "center" | "right" | "justify"
|
||||||
|
) => {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if ($isRangeSelection(selection)) {
|
||||||
|
$patchStyleText(selection, {
|
||||||
|
"text-align": alignment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toolbar">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
activeEditor.dispatchCommand(UNDO_COMMAND, undefined);
|
||||||
|
}}
|
||||||
|
title="Undo"
|
||||||
|
type="button"
|
||||||
|
aria-label="Undo"
|
||||||
|
>
|
||||||
|
<Undo className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
activeEditor.dispatchCommand(REDO_COMMAND, undefined);
|
||||||
|
}}
|
||||||
|
title="Redo"
|
||||||
|
type="button"
|
||||||
|
aria-label="Redo"
|
||||||
|
>
|
||||||
|
<Redo className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
|
||||||
|
}}
|
||||||
|
className={isBold ? "active" : ""}
|
||||||
|
title="Bold"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format text as bold"
|
||||||
|
>
|
||||||
|
<Bold className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
|
||||||
|
}}
|
||||||
|
className={isItalic ? "active" : ""}
|
||||||
|
title="Italic"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format text as italics"
|
||||||
|
>
|
||||||
|
<Italic className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
|
||||||
|
}}
|
||||||
|
className={isUnderline ? "active" : ""}
|
||||||
|
title="Underline"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format text to underlined"
|
||||||
|
>
|
||||||
|
<Underline className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
|
||||||
|
}}
|
||||||
|
className={isStrikethrough ? "active" : ""}
|
||||||
|
title="Strikethrough"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format text with a strikethrough"
|
||||||
|
>
|
||||||
|
<Strikethrough className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={formatLink}
|
||||||
|
className={isLink ? "active" : ""}
|
||||||
|
title="Link"
|
||||||
|
type="button"
|
||||||
|
aria-label="Insert link"
|
||||||
|
>
|
||||||
|
<Link className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => formatHeading("h1")}
|
||||||
|
className={blockType === "h1" ? "active" : ""}
|
||||||
|
title="Heading 1"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as heading 1"
|
||||||
|
>
|
||||||
|
<Heading1 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => formatHeading("h2")}
|
||||||
|
className={blockType === "h2" ? "active" : ""}
|
||||||
|
title="Heading 2"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as heading 2"
|
||||||
|
>
|
||||||
|
<Heading2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => formatHeading("h3")}
|
||||||
|
className={blockType === "h3" ? "active" : ""}
|
||||||
|
title="Heading 3"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as heading 3"
|
||||||
|
>
|
||||||
|
<Heading3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={formatBulletList}
|
||||||
|
className={blockType === "ul" ? "active" : ""}
|
||||||
|
title="Bullet List"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as bullet list"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={formatNumberedList}
|
||||||
|
className={blockType === "ol" ? "active" : ""}
|
||||||
|
title="Numbered List"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as numbered list"
|
||||||
|
>
|
||||||
|
<ListOrdered className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={formatQuote}
|
||||||
|
className={blockType === "quote" ? "active" : ""}
|
||||||
|
title="Quote"
|
||||||
|
type="button"
|
||||||
|
aria-label="Format as blockquote"
|
||||||
|
>
|
||||||
|
<Quote className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => formatTextAlignment("left")}
|
||||||
|
className={textAlignment === "left" ? "active" : ""}
|
||||||
|
title="Align Left"
|
||||||
|
type="button"
|
||||||
|
aria-label="Align text left"
|
||||||
|
>
|
||||||
|
<AlignLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => formatTextAlignment("center")}
|
||||||
|
className={textAlignment === "center" ? "active" : ""}
|
||||||
|
title="Align Center"
|
||||||
|
type="button"
|
||||||
|
aria-label="Align text center"
|
||||||
|
>
|
||||||
|
<AlignCenter className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => formatTextAlignment("right")}
|
||||||
|
className={textAlignment === "right" ? "active" : ""}
|
||||||
|
title="Align Right"
|
||||||
|
type="button"
|
||||||
|
aria-label="Align text right"
|
||||||
|
>
|
||||||
|
<AlignRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => formatTextAlignment("justify")}
|
||||||
|
className={textAlignment === "justify" ? "active" : ""}
|
||||||
|
title="Justify"
|
||||||
|
type="button"
|
||||||
|
aria-label="Justify text"
|
||||||
|
>
|
||||||
|
<AlignJustify className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedNode(selection: any) {
|
||||||
|
const anchor = selection.anchor;
|
||||||
|
const focus = selection.focus;
|
||||||
|
const anchorNode = selection.anchor.getNode();
|
||||||
|
const focusNode = selection.focus.getNode();
|
||||||
|
if (anchorNode === focusNode) {
|
||||||
|
return anchorNode;
|
||||||
|
}
|
||||||
|
const isBackward = selection.isBackward();
|
||||||
|
if (isBackward) {
|
||||||
|
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
|
||||||
|
} else {
|
||||||
|
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function $isAtNodeEnd(point: any) {
|
||||||
|
return point.offset === point.getNode().getTextContentSize();
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import PolicyEditor from "@/components/PolicyEditor";
|
||||||
import type { CreatePolicyPageMutation } from "./__generated__/CreatePolicyPageMutation.graphql";
|
import type { CreatePolicyPageMutation } from "./__generated__/CreatePolicyPageMutation.graphql";
|
||||||
|
|
||||||
const CreatePolicyMutation = graphql`
|
const CreatePolicyMutation = graphql`
|
||||||
@@ -39,6 +39,13 @@ export default function CreatePolicyPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"CreatePolicyPage state - content:",
|
||||||
|
content
|
||||||
|
? content.substring(0, 50) + (content.length > 50 ? "..." : "")
|
||||||
|
: "empty"
|
||||||
|
);
|
||||||
|
|
||||||
const [commitMutation] =
|
const [commitMutation] =
|
||||||
useMutation<CreatePolicyPageMutation>(CreatePolicyMutation);
|
useMutation<CreatePolicyPageMutation>(CreatePolicyMutation);
|
||||||
|
|
||||||
@@ -136,15 +143,13 @@ export default function CreatePolicyPage() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="content">Policy Content</Label>
|
<Label htmlFor="content">Policy Content</Label>
|
||||||
<Textarea
|
<div className="min-h-[300px]">
|
||||||
id="content"
|
<PolicyEditor
|
||||||
placeholder="Enter policy content"
|
initialContent={content}
|
||||||
value={content}
|
onChange={(html) => setContent(html)}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
className="min-h-[200px]"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Status</Label>
|
<Label>Status</Label>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { PolicyOverviewPageQuery as PolicyOverviewPageQueryType } from "./__generated__/PolicyOverviewPageQuery.graphql";
|
import type { PolicyOverviewPageQuery as PolicyOverviewPageQueryType } from "./__generated__/PolicyOverviewPageQuery.graphql";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import "../styles/policy-content.css";
|
||||||
|
|
||||||
const PolicyOverviewPageQuery = graphql`
|
const PolicyOverviewPageQuery = graphql`
|
||||||
query PolicyOverviewPageQuery($policyId: ID!) {
|
query PolicyOverviewPageQuery($policyId: ID!) {
|
||||||
@@ -102,8 +103,9 @@ function PolicyOverviewPageContent({
|
|||||||
<CardTitle>Policy Content</CardTitle>
|
<CardTitle>Policy Content</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="prose max-w-none">
|
<div className="prose prose-sm md:prose-base lg:prose-lg max-w-none">
|
||||||
<div
|
<div
|
||||||
|
className="policy-content"
|
||||||
dangerouslySetInnerHTML={{ __html: policy.content || "" }}
|
dangerouslySetInnerHTML={{ __html: policy.content || "" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
import { FileText } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import PolicyEditor from "@/components/PolicyEditor";
|
||||||
import type { UpdatePolicyPageQuery as UpdatePolicyPageQueryType } from "./__generated__/UpdatePolicyPageQuery.graphql";
|
import type { UpdatePolicyPageQuery as UpdatePolicyPageQueryType } from "./__generated__/UpdatePolicyPageQuery.graphql";
|
||||||
import type { UpdatePolicyPageMutation as UpdatePolicyPageMutationType } from "./__generated__/UpdatePolicyPageMutation.graphql";
|
import type { UpdatePolicyPageMutation as UpdatePolicyPageMutationType } from "./__generated__/UpdatePolicyPageMutation.graphql";
|
||||||
|
|
||||||
@@ -59,11 +59,20 @@ function UpdatePolicyPageContent({
|
|||||||
queryRef
|
queryRef
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("UpdatePolicyPage data:", data.node);
|
||||||
|
|
||||||
const [name, setName] = useState(data.node.name);
|
const [name, setName] = useState(data.node.name);
|
||||||
const [content, setContent] = useState(data.node.content);
|
const [content, setContent] = useState(data.node.content || "");
|
||||||
const [status, setStatus] = useState(data.node.status);
|
const [status, setStatus] = useState(data.node.status);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"UpdatePolicyPage state - content:",
|
||||||
|
content
|
||||||
|
? content.substring(0, 50) + (content.length > 50 ? "..." : "")
|
||||||
|
: "empty"
|
||||||
|
);
|
||||||
|
|
||||||
const [commitMutation] =
|
const [commitMutation] =
|
||||||
useMutation<UpdatePolicyPageMutationType>(UpdatePolicyMutation);
|
useMutation<UpdatePolicyPageMutationType>(UpdatePolicyMutation);
|
||||||
|
|
||||||
@@ -152,15 +161,13 @@ function UpdatePolicyPageContent({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="content">Policy Content</Label>
|
<Label htmlFor="content">Policy Content</Label>
|
||||||
<Textarea
|
<div className="min-h-[300px]">
|
||||||
id="content"
|
<PolicyEditor
|
||||||
placeholder="Enter policy content"
|
initialContent={content}
|
||||||
value={content}
|
onChange={(html) => setContent(html)}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
className="min-h-[200px]"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Status</Label>
|
<Label>Status</Label>
|
||||||
|
|||||||
127
apps/console/src/styles/policy-content.css
Normal file
127
apps/console/src/styles/policy-content.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/* Policy Content Styles */
|
||||||
|
.policy-content {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content h4 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content ul,
|
||||||
|
.policy-content ol {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content a {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content blockquote {
|
||||||
|
border-left: 4px solid hsl(var(--border));
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
font-style: italic;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content code {
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content pre {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content th,
|
||||||
|
.policy-content td {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content th {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-content hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user