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