Files
mintel.me/src/components/FileExample.astro
2026-01-14 17:15:10 +01:00

338 lines
9.2 KiB
Plaintext

---
// FileExample.astro - Static file display component with syntax highlighting
import 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 Props {
filename: string;
content: string;
language: string;
description?: string;
tags?: string[];
id: string;
}
const { filename, content, language, id } = Astro.props;
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 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',
};
const prismLanguage = prismLanguageMap[fileExtension] || 'markup';
const highlightedCode = Prism.highlight(
content,
Prism.languages[prismLanguage] || Prism.languages.markup,
prismLanguage,
);
---
<div
class="file-example w-full bg-white border border-slate-200/80 rounded-lg overflow-hidden"
data-file-example
data-expanded="false"
>
<div
class="px-3 py-2 flex items-center justify-between gap-3 cursor-pointer select-none bg-white hover:bg-slate-50/60 transition-colors"
data-file-header
role="button"
tabindex="0"
aria-expanded="false"
aria-controls={contentId}
id={headerId}
>
<div class="flex items-center gap-2 min-w-0">
<svg
class="w-3 h-3 text-slate-400 flex-shrink-0 toggle-icon transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
<span class="text-xs font-mono text-slate-900 truncate" title={filename}>{filename}</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0" onclick="event.stopPropagation()">
<button
type="button"
class="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"
data-content={content}
data-filename={filename}
title="Copy to clipboard"
aria-label={`Copy ${filename} to clipboard`}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="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"
class="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"
data-content={content}
data-filename={filename}
title="Download file"
aria-label={`Download ${filename}`}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="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
class="file-example__content max-h-0 opacity-0 overflow-hidden transition-[max-height,opacity] duration-200 ease-out bg-slate-50"
data-file-content
id={contentId}
role="region"
aria-labelledby={headerId}
>
<pre
class="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="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; max-height: 22rem;"
><code class={`language-${prismLanguage}`} set:html={highlightedCode}></code></pre>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-file-example]').forEach((node) => {
if (!(node instanceof HTMLElement)) return;
const header = node.querySelector('[data-file-header]');
const content = node.querySelector('[data-file-content]');
if (!(header instanceof HTMLElement) || !(content instanceof HTMLElement)) return;
const collapse = () => {
node.dataset.expanded = 'false';
header.setAttribute('aria-expanded', 'false');
content.classList.add('max-h-0', 'opacity-0');
content.classList.remove('max-h-[22rem]', 'opacity-100');
};
const expand = () => {
node.dataset.expanded = 'true';
header.setAttribute('aria-expanded', 'true');
content.classList.remove('max-h-0', 'opacity-0');
content.classList.add('max-h-[22rem]', 'opacity-100');
};
collapse();
const toggle = () => {
const isExpanded = node.dataset.expanded === 'true';
if (isExpanded) collapse();
else expand();
if (!isExpanded) {
setTimeout(() => {
node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 120);
}
};
header.addEventListener('click', toggle);
header.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
toggle();
});
});
document.querySelectorAll('.copy-btn').forEach((btn) => {
if (!(btn instanceof HTMLButtonElement)) return;
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const text = btn.dataset.content || '';
try {
await navigator.clipboard.writeText(text);
btn.dataset.copied = 'true';
setTimeout(() => {
delete btn.dataset.copied;
}, 900);
} catch (err) {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard');
}
});
});
document.querySelectorAll('.download-btn').forEach((btn) => {
if (!(btn instanceof HTMLButtonElement)) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const fileContent = btn.dataset.content || '';
const fileName = btn.dataset.filename || 'download.txt';
const blob = new Blob([fileContent], { 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);
});
});
});
</script>
<style is:global>
[data-file-example] {
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
}
[data-file-example][data-expanded='true'] .toggle-icon {
transform: rotate(0deg);
}
.toggle-icon {
transform: rotate(-90deg);
}
.copy-btn,
.download-btn {
color: #475569;
}
.copy-btn[data-copied='true'] {
color: #065f46;
background: rgba(16, 185, 129, 0.10);
border-color: rgba(16, 185, 129, 0.35);
}
/* Prism.js syntax highlighting - light, low-noise */
code[class*='language-'],
pre[class*='language-'],
pre:has(code[class*='language-']) {
color: #0f172a;
background: transparent;
text-shadow: none;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.8125rem;
line-height: 1.65;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
tab-size: 2;
hyphens: none;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #64748b;
font-style: italic;
}
.token.punctuation {
color: #94a3b8;
}
.token.operator {
color: #64748b;
}
.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
color: #c2410c;
}
.token.boolean,
.token.number {
color: #a16207;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #059669;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #475569;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #7c3aed;
font-weight: 500;
}
.token.function,
.token.class-name {
color: #2563eb;
}
.token.regex,
.token.important,
.token.variable {
color: #db2777;
}
</style>