Files
mintel.me/src/components/FileExample.tsx
2026-01-29 21:50:28 +01:00

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