website: components: add multilinecodeblock component (#13179)
* wip * wip * wip Signed-off-by: Dominic R <dominic@sdko.org> wip Signed-off-by: Dominic R <dominic@sdko.org> wip * wip * wip * move css to same folder Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
188
website/src/components/MultilineCodeblock/index.tsx
Normal file
188
website/src/components/MultilineCodeblock/index.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import React, {
|
||||||
|
ReactNode,
|
||||||
|
useState,
|
||||||
|
isValidElement,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import createDOMPurify from "dompurify";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
type IntegrationsMultilineCodeblockProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CopyButtonState = {
|
||||||
|
isCopied: boolean;
|
||||||
|
className: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configuration for allowed HTML tags in the sanitized output
|
||||||
|
const allowedTags = ["em", "code", "pre"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes DOMPurify safely for both browser and server environments
|
||||||
|
* @returns DOMPurify instance or null
|
||||||
|
*/
|
||||||
|
const getDOMPurify = () => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return createDOMPurify(window);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const domPurifyInstance = getDOMPurify();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for rendering secure code blocks with copy functionality
|
||||||
|
* - Safely sanitizes HTML content
|
||||||
|
* - Handles both string and JSX content
|
||||||
|
* - Provides copy-to-clipboard functionality
|
||||||
|
*/
|
||||||
|
const IntegrationsMultilineCodeblock: React.FC<
|
||||||
|
IntegrationsMultilineCodeblockProps
|
||||||
|
> = ({ children, className = "" }) => {
|
||||||
|
// State for managing copy button appearance and behavior
|
||||||
|
const [copyState, setCopyState] = useState<CopyButtonState>({
|
||||||
|
isCopied: false,
|
||||||
|
className: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// State for storing sanitized content after processing
|
||||||
|
const [sanitizedContent, setSanitizedContent] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ref to access the actual DOM element for text extraction
|
||||||
|
const codeRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively converts React children to plain text string
|
||||||
|
* @param nodes - React children nodes to process
|
||||||
|
* @returns Flattened string representation
|
||||||
|
*/
|
||||||
|
const childrenAsString = useCallback((nodes: ReactNode): string => {
|
||||||
|
return React.Children.toArray(nodes).reduce<string>((acc, node) => {
|
||||||
|
if (typeof node === "string") {
|
||||||
|
return acc + node;
|
||||||
|
} else if (isValidElement(node)) {
|
||||||
|
return acc + childrenAsString(node.props.children);
|
||||||
|
} else if (typeof node === "number") {
|
||||||
|
return acc + String(node);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, "");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes content while preserving allowed HTML tags
|
||||||
|
* @param children - React children to process
|
||||||
|
* @returns Sanitized HTML string
|
||||||
|
*/
|
||||||
|
const processContent = useCallback(
|
||||||
|
(children: ReactNode): string => {
|
||||||
|
const rawText = childrenAsString(children);
|
||||||
|
|
||||||
|
// Client-side sanitization with DOMPurify
|
||||||
|
if (domPurifyInstance) {
|
||||||
|
return domPurifyInstance
|
||||||
|
.sanitize(rawText, {
|
||||||
|
ALLOWED_TAGS: allowedTags,
|
||||||
|
KEEP_CONTENT: true,
|
||||||
|
RETURN_TRUSTED_TYPE: false,
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side fallback (no sanitization, will be re-processed client-side)
|
||||||
|
return rawText.trim();
|
||||||
|
},
|
||||||
|
[childrenAsString],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process content after component mounts to ensure browser environment
|
||||||
|
useEffect(() => {
|
||||||
|
setSanitizedContent(processContent(children));
|
||||||
|
}, [children, processContent]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles copy-to-clipboard functionality
|
||||||
|
*/
|
||||||
|
const handleCopy = async (): Promise<void> => {
|
||||||
|
// Get raw text content from DOM element, stripping all HTML tags
|
||||||
|
const textToCopy = codeRef.current?.textContent || "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(textToCopy);
|
||||||
|
setCopyState({
|
||||||
|
isCopied: true,
|
||||||
|
className: "integration-codeblock__copy-btn--copied",
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopyState((prev) =>
|
||||||
|
prev.isCopied ? { isCopied: false, className: "" } : prev,
|
||||||
|
);
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to copy content:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// SVG icon component for copy buttons
|
||||||
|
const Icon: React.FC<{ className: string; path: string }> = React.memo(
|
||||||
|
({ className, path }) => (
|
||||||
|
<svg viewBox="0 0 24 24" className={className}>
|
||||||
|
<path fill="currentColor" d={path} />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre className={`integration-codeblock ${className}`.trim()}>
|
||||||
|
{/* Attach ref to access rendered text content */}
|
||||||
|
<code className="integration-codeblock__content" ref={codeRef}>
|
||||||
|
{typeof children === "string" ? (
|
||||||
|
// Render sanitized HTML for string content
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: sanitizedContent || children,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Directly render JSX children (already safe)
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
{/* Copy button with state-dependent styling */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`integration-codeblock__copy-btn ${copyState.className}`.trim()}
|
||||||
|
aria-label="Copy code to clipboard"
|
||||||
|
title="Copy"
|
||||||
|
type="button"
|
||||||
|
disabled={copyState.isCopied}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="integration-codeblock__copy-icons"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Copy icon */}
|
||||||
|
<Icon
|
||||||
|
className="integration-codeblock__copy-icon"
|
||||||
|
path="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"
|
||||||
|
/>
|
||||||
|
{/* Success checkmark */}
|
||||||
|
<Icon
|
||||||
|
className="integration-codeblock__copy-success-icon"
|
||||||
|
path="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IntegrationsMultilineCodeblock;
|
112
website/src/components/MultilineCodeblock/styles.css
Normal file
112
website/src/components/MultilineCodeblock/styles.css
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
/* Base styles */
|
||||||
|
.integration-codeblock {
|
||||||
|
position: relative;
|
||||||
|
background: #f8f8f8;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: "Menlo", "Consolas", monospace;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 1rem 2rem 1.5rem 1.5rem;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.9em;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock:hover {
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock code {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: block;
|
||||||
|
background: transparent !important;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy button */
|
||||||
|
.integration-codeblock__copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-btn:focus-visible {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-btn[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-icons {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-icon,
|
||||||
|
.integration-codeblock__copy-success-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-success-icon {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-btn--copied .integration-codeblock__copy-icon {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-btn--copied
|
||||||
|
.integration-codeblock__copy-success-icon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.integration-codeblock {
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock:hover {
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-btn {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-codeblock__copy-success-icon {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user