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:
Dominic R
2025-03-21 11:08:24 -04:00
committed by GitHub
parent 50e2f1c474
commit 37a2eff716
2 changed files with 300 additions and 0 deletions

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

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