Compare commits
No commits in common. "ea66086b44dbb077a263a58a023bc50bfb07f451" and "99e47685aacd3d4e90fffdc558fdd09d6c3d5ebf" have entirely different histories.
ea66086b44
...
99e47685aa
@ -4,6 +4,8 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: prod
|
target: prod
|
||||||
|
volumes:
|
||||||
|
- notebooks:/app/notebooks
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
networks:
|
networks:
|
||||||
@ -28,6 +30,9 @@ services:
|
|||||||
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
|
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
|
||||||
caddy.reverse_proxy_1: "{{upstreams 4321}}"
|
caddy.reverse_proxy_1: "{{upstreams 4321}}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
notebooks:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
caddy:
|
caddy:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@ -1,27 +1,20 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import react from '@astrojs/react';
|
import react from '@astrojs/react';
|
||||||
import node from '@astrojs/node';
|
import node from '@astrojs/node';
|
||||||
import icon from 'astro-icon';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'server',
|
output: 'server',
|
||||||
adapter: node({ mode: 'standalone' }),
|
adapter: node({ mode: 'standalone' }),
|
||||||
integrations: [
|
integrations: [react()],
|
||||||
react(),
|
|
||||||
icon({ include: { lucide: ['plus', 'book-open', 'zap', 'cpu'] } }),
|
|
||||||
],
|
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
devToolbar: { enabled: false },
|
devToolbar: { enabled: false },
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
ssr: { external: ['@resvg/resvg-js'] },
|
|
||||||
build: {
|
build: {
|
||||||
// CodeMirror + uPlot + React naturally exceeds 500kB minified
|
// CodeMirror + uPlot + React naturally exceeds 500kB minified
|
||||||
chunkSizeWarningLimit: 700,
|
chunkSizeWarningLimit: 700,
|
||||||
rollupOptions: { external: ['@resvg/resvg-js'] },
|
|
||||||
},
|
},
|
||||||
optimizeDeps: { exclude: ['@resvg/resvg-js'] },
|
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
...(process.env.VITE_HMR_HOST && {
|
...(process.env.VITE_HMR_HOST && {
|
||||||
|
|||||||
1016
frontend/package-lock.json
generated
1016
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -19,19 +19,14 @@
|
|||||||
"@codemirror/language": "^6.10.0",
|
"@codemirror/language": "^6.10.0",
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"@codemirror/view": "^6.35.0",
|
"@codemirror/view": "^6.35.0",
|
||||||
"@iconify-json/lucide": "^1.2.90",
|
|
||||||
"@lezer/highlight": "^1.2.0",
|
"@lezer/highlight": "^1.2.0",
|
||||||
"@lezer/lr": "^1.4.0",
|
"@lezer/lr": "^1.4.0",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
|
||||||
"astro": "^5.0.0",
|
"astro": "^5.0.0",
|
||||||
"astro-icon": "^1.1.5",
|
|
||||||
"astro-seo-meta": "^5.2.0",
|
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"satori": "^0.19.2",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"uplot": "^1.6.31",
|
"uplot": "^1.6.31",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
frontend/src/env.d.ts
vendored
2
frontend/src/env.d.ts
vendored
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly PUBLIC_API_URL: string;
|
readonly PUBLIC_API_URL: string;
|
||||||
readonly BACKEND_INTERNAL_URL: string;
|
|
||||||
readonly SITE: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@ -13,7 +13,6 @@ const themeClass = theme === 'dark' ? 'dark' : 'light';
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-sb-bg text-sb-text min-h-screen antialiased overflow-x-hidden">
|
<body class="bg-sb-bg text-sb-text min-h-screen antialiased overflow-x-hidden">
|
||||||
|
|||||||
@ -1,27 +1,9 @@
|
|||||||
---
|
---
|
||||||
import { Seo } from 'astro-seo-meta';
|
interface Props {
|
||||||
import type { SEOProps } from '../lib/seo';
|
|
||||||
import { SEO_DEFAULTS } from '../lib/seo';
|
|
||||||
|
|
||||||
interface Props extends SEOProps {
|
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { title = 'SpiceBook' } = Astro.props;
|
||||||
title = 'SpiceBook',
|
|
||||||
description,
|
|
||||||
ogImage,
|
|
||||||
ogType = 'website',
|
|
||||||
noindex = false,
|
|
||||||
canonicalPath,
|
|
||||||
} = Astro.props;
|
|
||||||
|
|
||||||
const pageTitle = title === 'SpiceBook' ? title : `${title} | SpiceBook`;
|
|
||||||
const pageDescription = description || SEO_DEFAULTS.defaultDescription;
|
|
||||||
const ogImageUrl = `${SEO_DEFAULTS.siteUrl}${ogImage || SEO_DEFAULTS.defaultOgImage}`;
|
|
||||||
const canonicalUrl = canonicalPath
|
|
||||||
? `${SEO_DEFAULTS.siteUrl}${canonicalPath}`
|
|
||||||
: undefined;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@ -29,25 +11,9 @@ const canonicalUrl = canonicalPath
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="SpiceBook — Notebook interface for SPICE circuit simulation" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
<title>{title}</title>
|
||||||
<Seo
|
|
||||||
title={pageTitle}
|
|
||||||
description={pageDescription}
|
|
||||||
icon="/favicon.svg"
|
|
||||||
colorScheme="dark"
|
|
||||||
robots={noindex ? 'noindex, nofollow' : 'index, follow'}
|
|
||||||
facebook={{
|
|
||||||
image: ogImageUrl,
|
|
||||||
url: canonicalUrl || SEO_DEFAULTS.siteUrl,
|
|
||||||
type: ogType,
|
|
||||||
}}
|
|
||||||
twitter={{
|
|
||||||
image: ogImageUrl,
|
|
||||||
card: 'summary_large_image',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<meta property="og:site_name" content={SEO_DEFAULTS.siteName} />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
|
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
export const NOTEBOOK_ID_RE = /^[a-zA-Z0-9_\-]+$/;
|
|
||||||
|
|
||||||
export function buildNotebookDescription(
|
|
||||||
title: string,
|
|
||||||
engine: string,
|
|
||||||
tags: string[]
|
|
||||||
): string {
|
|
||||||
return (
|
|
||||||
`${title} — ${engine} simulation` +
|
|
||||||
(tags.length ? `. Tags: ${tags.join(', ')}` : '')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
import satori from 'satori';
|
|
||||||
import { Resvg } from '@resvg/resvg-js';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
interface OgImageProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
engine?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFont(): ArrayBuffer {
|
|
||||||
// In prod: dist/client/fonts/ In dev: public/fonts/
|
|
||||||
const candidates = [
|
|
||||||
join(process.cwd(), 'dist', 'client', 'fonts', 'Inter-SemiBold.ttf'),
|
|
||||||
join(process.cwd(), 'public', 'fonts', 'Inter-SemiBold.ttf'),
|
|
||||||
];
|
|
||||||
for (const path of candidates) {
|
|
||||||
try {
|
|
||||||
return readFileSync(path).buffer as ArrayBuffer;
|
|
||||||
} catch {
|
|
||||||
// try next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Inter-SemiBold.ttf not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
let fontData: ArrayBuffer | null = null;
|
|
||||||
|
|
||||||
function getFont(): ArrayBuffer {
|
|
||||||
if (!fontData) {
|
|
||||||
try {
|
|
||||||
fontData = loadFont();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[og-renderer] Font load failed:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fontData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small waveform logo — a sine-like path rendered as SVG text
|
|
||||||
function WaveformLogo() {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<svg
|
|
||||||
width="40"
|
|
||||||
height="40"
|
|
||||||
viewBox="0 0 40 40"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<rect width="40" height="40" rx="8" fill="#1e3a5f" />
|
|
||||||
<path
|
|
||||||
d="M6 20 Q10 8, 14 20 Q18 32, 22 20 Q26 8, 30 20 Q34 32, 38 20"
|
|
||||||
stroke="#60a5fa"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span style={{ fontSize: '24px', color: '#94a3b8', fontWeight: 600 }}>
|
|
||||||
SpiceBook
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renderOgImage({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
engine,
|
|
||||||
}: OgImageProps): Promise<Uint8Array> {
|
|
||||||
const svg = await satori(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '1200px',
|
|
||||||
height: '630px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '60px',
|
|
||||||
backgroundColor: '#020617', // slate-950
|
|
||||||
fontFamily: 'Inter',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Top: branding */}
|
|
||||||
<WaveformLogo />
|
|
||||||
|
|
||||||
{/* Center: title + description */}
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: title.length > 40 ? '42px' : '52px',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: '#f1f5f9', // slate-100
|
|
||||||
lineHeight: 1.2,
|
|
||||||
maxWidth: '1000px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '24px',
|
|
||||||
color: '#94a3b8', // slate-400
|
|
||||||
lineHeight: 1.4,
|
|
||||||
maxWidth: '900px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description.length > 120
|
|
||||||
? description.slice(0, 117) + '...'
|
|
||||||
: description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom: engine badge + accent bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{engine ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: '18px',
|
|
||||||
color: '#64748b', // slate-500
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
padding: '6px 14px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
backgroundColor: '#0f172a', // slate-900
|
|
||||||
border: '1px solid #1e293b', // slate-800
|
|
||||||
color: '#60a5fa', // blue-400
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{engine}
|
|
||||||
</div>
|
|
||||||
<span>circuit simulation</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex' }} />
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
fontSize: '16px',
|
|
||||||
color: '#475569', // slate-600
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
spicebook.warehack.ing
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Accent bar at very bottom */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '0',
|
|
||||||
left: '0',
|
|
||||||
right: '0',
|
|
||||||
height: '4px',
|
|
||||||
background: 'linear-gradient(90deg, #2563eb, #60a5fa, #2563eb)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
fonts: [
|
|
||||||
{
|
|
||||||
name: 'Inter',
|
|
||||||
data: getFont(),
|
|
||||||
weight: 600,
|
|
||||||
style: 'normal',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const resvg = new Resvg(svg, {
|
|
||||||
fitTo: { mode: 'width', value: 1200 },
|
|
||||||
});
|
|
||||||
const pngBuffer = resvg.render().asPng();
|
|
||||||
return new Uint8Array(pngBuffer);
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
export interface SEOProps {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
ogImage?: string;
|
|
||||||
ogType?: 'website' | 'article';
|
|
||||||
noindex?: boolean;
|
|
||||||
canonicalPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SEO_DEFAULTS = {
|
|
||||||
siteName: 'SpiceBook',
|
|
||||||
defaultDescription: 'Notebook interface for SPICE circuit simulation',
|
|
||||||
defaultOgImage: '/og/default.png',
|
|
||||||
siteUrl: import.meta.env.SITE || 'https://spicebook.warehack.ing',
|
|
||||||
} as const;
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
export const INTERNAL_API_BASE =
|
|
||||||
import.meta.env.BACKEND_INTERNAL_URL || 'http://localhost:8099';
|
|
||||||
|
|
||||||
export async function fetchNotebookMeta(id: string) {
|
|
||||||
const url = `${INTERNAL_API_BASE}/api/notebooks/${encodeURIComponent(id)}`;
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
const res = await fetch(url, { signal: controller.signal });
|
|
||||||
clearTimeout(timeout);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.warn(`[server-api] fetchNotebookMeta(${id}): HTTP ${res.status}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Error && err.name === 'AbortError') {
|
|
||||||
console.error(`[server-api] fetchNotebookMeta(${id}): timed out after 5s`);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
`[server-api] fetchNotebookMeta(${id}):`,
|
|
||||||
err instanceof Error ? err.message : err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +1,9 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components';
|
|
||||||
import NotebookLayout from '../layouts/NotebookLayout.astro';
|
import NotebookLayout from '../layouts/NotebookLayout.astro';
|
||||||
import NotebookList from '../components/NotebookList';
|
import NotebookList from '../components/NotebookList';
|
||||||
---
|
---
|
||||||
|
|
||||||
<NotebookLayout
|
<NotebookLayout title="SpiceBook">
|
||||||
title="SpiceBook"
|
|
||||||
description="Notebook interface for SPICE circuit simulation"
|
|
||||||
canonicalPath="/"
|
|
||||||
>
|
|
||||||
<div class="max-w-5xl mx-auto px-6 py-10">
|
<div class="max-w-5xl mx-auto px-6 py-10">
|
||||||
<!-- Header (static) -->
|
<!-- Header (static) -->
|
||||||
<header class="mb-10">
|
<header class="mb-10">
|
||||||
@ -21,7 +16,7 @@ import NotebookList from '../components/NotebookList';
|
|||||||
href="/notebook/new"
|
href="/notebook/new"
|
||||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
|
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
|
||||||
>
|
>
|
||||||
<Icon name="lucide:plus" class="w-4 h-4" />
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||||
New Notebook
|
New Notebook
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,28 +1,10 @@
|
|||||||
---
|
---
|
||||||
import NotebookLayout from '../../layouts/NotebookLayout.astro';
|
import NotebookLayout from '../../layouts/NotebookLayout.astro';
|
||||||
import NotebookEditor from '../../components/notebook/NotebookEditor';
|
import NotebookEditor from '../../components/notebook/NotebookEditor';
|
||||||
import { fetchNotebookMeta } from '../../lib/server-api';
|
|
||||||
import { NOTEBOOK_ID_RE, buildNotebookDescription } from '../../lib/notebook-meta';
|
|
||||||
|
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
|
|
||||||
if (!id || !NOTEBOOK_ID_RE.test(id)) {
|
|
||||||
return new Response('Invalid notebook ID', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const notebook = await fetchNotebookMeta(id);
|
|
||||||
const title = notebook?.metadata?.title || 'Notebook';
|
|
||||||
const engine = notebook?.metadata?.engine || 'ngspice';
|
|
||||||
const tags: string[] = notebook?.metadata?.tags || [];
|
|
||||||
const description = buildNotebookDescription(title, engine, tags);
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<NotebookLayout
|
<NotebookLayout title="SpiceBook">
|
||||||
title={title}
|
<NotebookEditor notebookId={id!} client:load />
|
||||||
description={description}
|
|
||||||
ogImage={`/og/${id}.png`}
|
|
||||||
ogType="article"
|
|
||||||
canonicalPath={`/notebook/${id}`}
|
|
||||||
>
|
|
||||||
<NotebookEditor notebookId={id} client:load />
|
|
||||||
</NotebookLayout>
|
</NotebookLayout>
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { renderOgImage } from '../../lib/og-renderer';
|
|
||||||
import { fetchNotebookMeta } from '../../lib/server-api';
|
|
||||||
import { NOTEBOOK_ID_RE, buildNotebookDescription } from '../../lib/notebook-meta';
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params }) => {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
if (!id || !NOTEBOOK_ID_RE.test(id)) {
|
|
||||||
return new Response('Invalid notebook ID', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const notebook = await fetchNotebookMeta(id);
|
|
||||||
const title = notebook?.metadata?.title || id;
|
|
||||||
const engine = notebook?.metadata?.engine || 'ngspice';
|
|
||||||
const tags: string[] = notebook?.metadata?.tags || [];
|
|
||||||
const description = buildNotebookDescription(title, engine, tags);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const png = await renderOgImage({ title, description, engine });
|
|
||||||
return new Response(png.buffer as ArrayBuffer, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'image/png',
|
|
||||||
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[og/${id}] render failed:`, err);
|
|
||||||
return new Response('OG image generation failed', { status: 500 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { renderOgImage } from '../../lib/og-renderer';
|
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
|
||||||
try {
|
|
||||||
const png = await renderOgImage({
|
|
||||||
title: 'SpiceBook',
|
|
||||||
description: 'Notebook interface for SPICE circuit simulation',
|
|
||||||
});
|
|
||||||
return new Response(png.buffer as ArrayBuffer, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'image/png',
|
|
||||||
'Cache-Control': 'public, max-age=86400, s-maxage=604800',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[og/default] render failed:', err);
|
|
||||||
return new Response('OG image generation failed', { status: 500 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Loading…
x
Reference in New Issue
Block a user