wip
This commit is contained in:
336
src/components/FileExample.astro
Normal file
336
src/components/FileExample.astro
Normal file
@@ -0,0 +1,336 @@
|
||||
---
|
||||
// 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"
|
||||
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-'] {
|
||||
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>
|
||||
Reference in New Issue
Block a user