migrate to nextjs
This commit is contained in:
192
src/components/FileExample.tsx
Normal file
192
src/components/FileExample.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'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/80 rounded-lg overflow-hidden"
|
||||
data-file-example
|
||||
data-expanded={isExpanded}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 flex items-center justify-between gap-3 cursor-pointer select-none bg-white hover:bg-slate-50/60 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-slate-50 ${isExpanded ? 'max-h-[22rem] opacity-100' : 'max-h-0 opacity-0'}`}
|
||||
id={contentId}
|
||||
role="region"
|
||||
aria-labelledby={headerId}
|
||||
>
|
||||
<pre
|
||||
className="m-0 p-3 overflow-x-auto overflow-y-auto text-[13px] leading-[1.65] font-mono text-slate-800 hide-scrollbar border border-slate-200 rounded"
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user