193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useRef } from 'react';
|
|
import * as Prism from 'prismjs';
|
|
import 'prismjs/components/prism-python';
|
|
import 'prismjs/components/prism-typescript';
|
|
import 'prismjs/components/prism-javascript';
|
|
import 'prismjs/components/prism-jsx';
|
|
import 'prismjs/components/prism-tsx';
|
|
import 'prismjs/components/prism-docker';
|
|
import 'prismjs/components/prism-yaml';
|
|
import 'prismjs/components/prism-json';
|
|
import 'prismjs/components/prism-markup';
|
|
import 'prismjs/components/prism-css';
|
|
import 'prismjs/components/prism-sql';
|
|
import 'prismjs/components/prism-bash';
|
|
import 'prismjs/components/prism-markdown';
|
|
|
|
interface FileExampleProps {
|
|
filename: string;
|
|
content: string;
|
|
language: string;
|
|
description?: string;
|
|
tags?: string[];
|
|
id: string;
|
|
}
|
|
|
|
const prismLanguageMap: Record<string, string> = {
|
|
py: 'python',
|
|
ts: 'typescript',
|
|
tsx: 'tsx',
|
|
js: 'javascript',
|
|
jsx: 'jsx',
|
|
dockerfile: 'docker',
|
|
docker: 'docker',
|
|
yml: 'yaml',
|
|
yaml: 'yaml',
|
|
json: 'json',
|
|
html: 'markup',
|
|
css: 'css',
|
|
sql: 'sql',
|
|
sh: 'bash',
|
|
bash: 'bash',
|
|
md: 'markdown',
|
|
};
|
|
|
|
export const FileExample: React.FC<FileExampleProps> = ({
|
|
filename,
|
|
content,
|
|
language,
|
|
id
|
|
}) => {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [isCopied, setIsCopied] = useState(false);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
const safeId = String(id).replace(/[^a-zA-Z0-9_-]/g, '');
|
|
const headerId = `file-example-header-${safeId}`;
|
|
const contentId = `file-example-content-${safeId}`;
|
|
|
|
const fileExtension = filename.split('.').pop() || language;
|
|
const prismLanguage = prismLanguageMap[fileExtension] || 'markup';
|
|
|
|
const highlightedCode = Prism.highlight(
|
|
content,
|
|
Prism.languages[prismLanguage] || Prism.languages.markup,
|
|
prismLanguage,
|
|
);
|
|
|
|
const toggleExpand = () => {
|
|
setIsExpanded(!isExpanded);
|
|
if (!isExpanded) {
|
|
setTimeout(() => {
|
|
contentRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}, 120);
|
|
}
|
|
};
|
|
|
|
const handleCopy = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await navigator.clipboard.writeText(content);
|
|
setIsCopied(true);
|
|
setTimeout(() => setIsCopied(false), 900);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
};
|
|
|
|
const handleDownload = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="file-example w-full bg-white border border-slate-200 rounded-2xl overflow-hidden transition-all duration-300"
|
|
data-file-example
|
|
data-expanded={isExpanded}
|
|
>
|
|
<div
|
|
className="px-4 py-3 flex items-center justify-between gap-3 cursor-pointer select-none bg-white hover:bg-slate-50 transition-colors"
|
|
onClick={toggleExpand}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
toggleExpand();
|
|
}
|
|
}}
|
|
aria-expanded={isExpanded}
|
|
aria-controls={contentId}
|
|
id={headerId}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<svg
|
|
className={`w-3 h-3 text-slate-400 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
|
|
<span className="text-xs font-mono text-slate-900 truncate" title={filename}>{filename}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
<button
|
|
type="button"
|
|
className={`copy-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white transition-colors ${isCopied ? 'copied' : ''}`}
|
|
onClick={handleCopy}
|
|
title="Copy to clipboard"
|
|
aria-label={`Copy ${filename} to clipboard`}
|
|
data-copied={isCopied}
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="2"
|
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className="download-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white transition-colors"
|
|
onClick={handleDownload}
|
|
title="Download file"
|
|
aria-label={`Download ${filename}`}
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="2"
|
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
ref={contentRef}
|
|
className={`file-example__content overflow-hidden transition-[max-height,opacity] duration-200 ease-out bg-white ${isExpanded ? 'max-h-[22rem] opacity-100' : 'max-h-0 opacity-0'}`}
|
|
id={contentId}
|
|
role="region"
|
|
aria-labelledby={headerId}
|
|
>
|
|
<pre
|
|
className="m-0 p-6 overflow-x-auto overflow-y-auto text-[13px] leading-[1.65] font-mono text-slate-800 hide-scrollbar border-t border-slate-200"
|
|
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", maxHeight: "22rem" }}
|
|
>
|
|
<code className={`language-${prismLanguage}`} dangerouslySetInnerHTML={{ __html: highlightedCode }}></code>
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|