mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Showcase: render workflow graphs as SVGs, add nav, fix prompt
- Render DOT workflow definitions as visual SVG diagrams at build time using @viz-js/viz, replacing raw code blocks on show pages and placeholder first-letter thumbnails on index cards - Collapse models/skills/languages into a compact metadata strip on show pages instead of separate boxed sections - Fix prompt expand/collapse to use a single DOM element with max-height animation instead of duplicating the text in two swapped containers - Add prev/next navigation links at the bottom of show pages - Extract duplicated langIcons data into shared src/lib/langIcons.ts - Use varied reveal animation types (reveal-scale, reveal-left) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
359306569a
commit
ae4aa1e04c
6 changed files with 165 additions and 111 deletions
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@astrojs/react": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@viz-js/viz": "^3.25.0",
|
||||
"astro": "^5.9.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
|
|
|||
18
apps/marketing/src/lib/langIcons.ts
Normal file
18
apps/marketing/src/lib/langIcons.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export const langIcons: Record<string, { label: string; path: string }> = {
|
||||
python: {
|
||||
label: "Python",
|
||||
path: "M12 1.5c-5.1 0-4.8 2.2-4.8 2.2l.006 2.3H12.4v.7H4.8S1.5 6.3 1.5 11.5s2.9 5 2.9 5h1.7V14.1s-.1-2.9 2.8-2.9h4.9s2.8 0 2.8-2.7V3.8s.4-2.3-4.6-2.3zm-2.7 1.3a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM12 22.5c5.1 0 4.8-2.2 4.8-2.2l-.006-2.3H11.6v-.7h7.6s3.3.4 3.3-4.8-2.9-5-2.9-5h-1.7v2.4s.1 2.9-2.8 2.9h-4.9s-2.8 0-2.8 2.7v4.7s-.4 2.3 4.6 2.3zm2.7-1.3a.9.9 0 1 1 0-1.8.9.9 0 0 1 0 1.8z",
|
||||
},
|
||||
rust: {
|
||||
label: "Rust",
|
||||
path: "M23.8 14.1l-1.2-.7a10.5 10.5 0 0 0 0-2.8l1.2-.7a.3.3 0 0 0 .1-.4 12 12 0 0 0-2.6-4.5.3.3 0 0 0-.4 0l-1.2.7a10.2 10.2 0 0 0-2.4-1.4V3a.3.3 0 0 0-.2-.3 12 12 0 0 0-5.2 0 .3.3 0 0 0-.2.3v1.3a10.2 10.2 0 0 0-2.4 1.4l-1.2-.7a.3.3 0 0 0-.4 0A12 12 0 0 0 5.1 9.5a.3.3 0 0 0 .1.4l1.2.7a10.5 10.5 0 0 0 0 2.8l-1.2.7a.3.3 0 0 0-.1.4 12 12 0 0 0 2.6 4.5.3.3 0 0 0 .4 0l1.2-.7c.7.6 1.5 1 2.4 1.4V21a.3.3 0 0 0 .2.3 12 12 0 0 0 5.2 0 .3.3 0 0 0 .2-.3v-1.3c.9-.4 1.7-.8 2.4-1.4l1.2.7a.3.3 0 0 0 .4 0 12 12 0 0 0 2.6-4.5.3.3 0 0 0-.1-.4zM12 16.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9z",
|
||||
},
|
||||
typescript: {
|
||||
label: "TypeScript",
|
||||
path: "M1.5 1.5h21v21h-21V1.5zm10.2 10.2v-1.8h6.6v1.8h-2.3v6.6h-2v-6.6h-2.3zm-4.1-1.8h2v4.5c0 .7.1 1.2.3 1.5.4.5 1 .8 1.8.8s1.4-.3 1.8-.8c.2-.3.3-.8.3-1.5V9.9h2v4.7c0 1-.2 1.8-.7 2.4-.7.9-1.8 1.3-3.4 1.3s-2.7-.4-3.4-1.3c-.5-.6-.7-1.4-.7-2.4V9.9z",
|
||||
},
|
||||
ruby: {
|
||||
label: "Ruby",
|
||||
path: "M20.6 18.3L21.7 5l-5.5 1.5-2.5-3.5-3 3.2L4.3 3 3 10.5l3.3 2-3 4.2 5.3.8 1.5 4 4.5-2.8 4 3.6 2-4z",
|
||||
},
|
||||
};
|
||||
60
apps/marketing/src/lib/renderWorkflow.ts
Normal file
60
apps/marketing/src/lib/renderWorkflow.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { instance } from "@viz-js/viz";
|
||||
|
||||
let vizInstance: Awaited<ReturnType<typeof instance>> | undefined;
|
||||
|
||||
async function getViz() {
|
||||
if (!vizInstance) {
|
||||
vizInstance = await instance();
|
||||
}
|
||||
return vizInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Graphviz DOT string to an SVG string, styled to match the site's
|
||||
* teal-on-navy palette. The returned SVG has no fixed dimensions (uses viewBox)
|
||||
* so it can be sized by its container.
|
||||
*/
|
||||
export async function renderWorkflow(dot: string): Promise<string> {
|
||||
const viz = await getViz();
|
||||
|
||||
const styledDot = injectGraphStyle(dot);
|
||||
let svg = viz.renderString(styledDot, { format: "svg", engine: "dot" });
|
||||
|
||||
// Make SVG responsive: remove fixed width/height, keep viewBox
|
||||
svg = svg.replace(/\s*width="[^"]*"/, "");
|
||||
svg = svg.replace(/\s*height="[^"]*"/, "");
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects graph-level styling attributes into the DOT string so the rendered
|
||||
* SVG uses the site's color palette without post-processing.
|
||||
*/
|
||||
function injectGraphStyle(dot: string): string {
|
||||
const styleBlock = `
|
||||
bgcolor="transparent"
|
||||
node [
|
||||
fontname="Space Grotesk"
|
||||
fontsize=13
|
||||
fontcolor="#e8edf3"
|
||||
style="filled"
|
||||
fillcolor="#141c2f"
|
||||
color="#357f9e"
|
||||
penwidth=1.5
|
||||
shape=box
|
||||
margin="0.15,0.1"
|
||||
]
|
||||
edge [
|
||||
color="#4b5768"
|
||||
fontname="DM Sans"
|
||||
fontsize=10
|
||||
fontcolor="#a8b5c5"
|
||||
arrowsize=0.7
|
||||
penwidth=1.2
|
||||
]
|
||||
`;
|
||||
|
||||
// Insert style after the opening brace of the digraph
|
||||
return dot.replace(/\{/, `{\n${styleBlock}\n`);
|
||||
}
|
||||
|
|
@ -4,36 +4,26 @@ import Nav from "../../components/Nav.astro";
|
|||
import Footer from "../../components/Footer.astro";
|
||||
import PageScripts from "../../components/PageScripts.astro";
|
||||
import { getCollection, render } from "astro:content";
|
||||
import { langIcons } from "../../lib/langIcons";
|
||||
import { renderWorkflow } from "../../lib/renderWorkflow";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const entries = await getCollection("showcase");
|
||||
return entries.map((entry) => ({
|
||||
const entries = (await getCollection("showcase")).sort(
|
||||
(a, b) => a.data.sortOrder - b.data.sortOrder
|
||||
);
|
||||
return entries.map((entry, i) => ({
|
||||
params: { slug: entry.id },
|
||||
props: { entry },
|
||||
props: {
|
||||
entry,
|
||||
prev: entries[i - 1] ?? null,
|
||||
next: entries[i + 1] ?? null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { entry, prev, next } = Astro.props;
|
||||
const { Content } = await render(entry);
|
||||
|
||||
const langIcons: Record<string, { label: string; path: string }> = {
|
||||
python: {
|
||||
label: "Python",
|
||||
path: "M12 1.5c-5.1 0-4.8 2.2-4.8 2.2l.006 2.3H12.4v.7H4.8S1.5 6.3 1.5 11.5s2.9 5 2.9 5h1.7V14.1s-.1-2.9 2.8-2.9h4.9s2.8 0 2.8-2.7V3.8s.4-2.3-4.6-2.3zm-2.7 1.3a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM12 22.5c5.1 0 4.8-2.2 4.8-2.2l-.006-2.3H11.6v-.7h7.6s3.3.4 3.3-4.8-2.9-5-2.9-5h-1.7v2.4s.1 2.9-2.8 2.9h-4.9s-2.8 0-2.8 2.7v4.7s-.4 2.3 4.6 2.3zm2.7-1.3a.9.9 0 1 1 0-1.8.9.9 0 0 1 0 1.8z",
|
||||
},
|
||||
rust: {
|
||||
label: "Rust",
|
||||
path: "M23.8 14.1l-1.2-.7a10.5 10.5 0 0 0 0-2.8l1.2-.7a.3.3 0 0 0 .1-.4 12 12 0 0 0-2.6-4.5.3.3 0 0 0-.4 0l-1.2.7a10.2 10.2 0 0 0-2.4-1.4V3a.3.3 0 0 0-.2-.3 12 12 0 0 0-5.2 0 .3.3 0 0 0-.2.3v1.3a10.2 10.2 0 0 0-2.4 1.4l-1.2-.7a.3.3 0 0 0-.4 0A12 12 0 0 0 5.1 9.5a.3.3 0 0 0 .1.4l1.2.7a10.5 10.5 0 0 0 0 2.8l-1.2.7a.3.3 0 0 0-.1.4 12 12 0 0 0 2.6 4.5.3.3 0 0 0 .4 0l1.2-.7c.7.6 1.5 1 2.4 1.4V21a.3.3 0 0 0 .2.3 12 12 0 0 0 5.2 0 .3.3 0 0 0 .2-.3v-1.3c.9-.4 1.7-.8 2.4-1.4l1.2.7a.3.3 0 0 0 .4 0 12 12 0 0 0 2.6-4.5.3.3 0 0 0-.1-.4zM12 16.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9z",
|
||||
},
|
||||
typescript: {
|
||||
label: "TypeScript",
|
||||
path: "M1.5 1.5h21v21h-21V1.5zm10.2 10.2v-1.8h6.6v1.8h-2.3v6.6h-2v-6.6h-2.3zm-4.1-1.8h2v4.5c0 .7.1 1.2.3 1.5.4.5 1 .8 1.8.8s1.4-.3 1.8-.8c.2-.3.3-.8.3-1.5V9.9h2v4.7c0 1-.2 1.8-.7 2.4-.7.9-1.8 1.3-3.4 1.3s-2.7-.4-3.4-1.3c-.5-.6-.7-1.4-.7-2.4V9.9z",
|
||||
},
|
||||
ruby: {
|
||||
label: "Ruby",
|
||||
path: "M20.6 18.3L21.7 5l-5.5 1.5-2.5-3.5-3 3.2L4.3 3 3 10.5l3.3 2-3 4.2 5.3.8 1.5 4 4.5-2.8 4 3.6 2-4z",
|
||||
},
|
||||
};
|
||||
const workflowSvg = await renderWorkflow(entry.data.workflow);
|
||||
---
|
||||
|
||||
<Layout title={`${entry.data.title} — Showcase — Fabro`} description={entry.data.description}>
|
||||
|
|
@ -50,8 +40,8 @@ const langIcons: Record<string, { label: string; path: string }> = {
|
|||
Showcase
|
||||
</a>
|
||||
|
||||
<!-- Hero header -->
|
||||
<header class="mb-10 border-b border-navy-800/40 pb-8">
|
||||
<!-- Header -->
|
||||
<header class="mb-10">
|
||||
<h1 class="reveal reveal-d1 font-display text-3xl font-bold tracking-tight text-ice-50 sm:text-4xl leading-[1.15]">
|
||||
{entry.data.title}
|
||||
</h1>
|
||||
|
|
@ -59,12 +49,12 @@ const langIcons: Record<string, { label: string; path: string }> = {
|
|||
{entry.data.description}
|
||||
</p>
|
||||
|
||||
{/* Language icons + tags */}
|
||||
<div class="reveal reveal-d2 mt-5 flex flex-wrap items-center gap-3">
|
||||
{/* Compact metadata strip */}
|
||||
<div class="reveal reveal-d2 mt-5 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-ice-300/60">
|
||||
{entry.data.languages.map((lang) => {
|
||||
const icon = langIcons[lang];
|
||||
return icon ? (
|
||||
<span class="flex items-center gap-1.5 text-sm text-ice-300/60" title={icon.label}>
|
||||
<span class="flex items-center gap-1.5" title={icon.label}>
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d={icon.path} />
|
||||
</svg>
|
||||
|
|
@ -73,15 +63,17 @@ const langIcons: Record<string, { label: string; path: string }> = {
|
|||
) : null;
|
||||
})}
|
||||
<span class="text-ice-300/20">|</span>
|
||||
{entry.data.tags.map((tag) => (
|
||||
<span class="rounded-full border border-navy-800 bg-navy-900/60 px-3 py-1 text-xs text-ice-300/70">
|
||||
{tag}
|
||||
</span>
|
||||
{entry.data.models.map((model) => (
|
||||
<span class="font-mono text-xs text-teal-300/70">{model}</span>
|
||||
))}
|
||||
<span class="text-ice-300/20">|</span>
|
||||
{entry.data.skills.map((skill) => (
|
||||
<span class="font-mono text-xs text-ice-300/50">{skill}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* GitHub button */}
|
||||
<div class="reveal reveal-d3 mt-6">
|
||||
<div class="reveal reveal-d3 mt-5">
|
||||
<a
|
||||
href={entry.data.github}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-navy-600 px-4 py-2 text-sm font-medium text-ice-300 transition-all hover:border-teal-700/60 hover:text-ice-50"
|
||||
|
|
@ -94,54 +86,22 @@ const langIcons: Record<string, { label: string; path: string }> = {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Workflow section -->
|
||||
<section class="reveal reveal-d3 mb-10">
|
||||
<!-- Workflow graph — hero element -->
|
||||
<section class="reveal-scale reveal-d3 mb-10">
|
||||
<h2 class="font-display text-lg font-semibold text-ice-50 mb-4">Workflow</h2>
|
||||
<div class="rounded-xl border border-navy-800/60 bg-navy-950/80 p-6 overflow-x-auto">
|
||||
<pre class="font-mono text-sm leading-relaxed"><code class="showcase-dot">{entry.data.workflow}</code></pre>
|
||||
</div>
|
||||
<div class="showcase-graph-hero rounded-xl border border-navy-800/60 bg-navy-950/80 p-6 flex items-center justify-center overflow-x-auto" set:html={workflowSvg} />
|
||||
</section>
|
||||
|
||||
<!-- Models & Skills -->
|
||||
<section class="reveal reveal-d4 mb-10">
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<h2 class="font-display text-lg font-semibold text-ice-50 mb-3">Models</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{entry.data.models.map((model) => (
|
||||
<span class="rounded-lg border border-teal-700/30 bg-teal-500/10 px-3 py-1.5 text-xs font-mono text-teal-300">
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-display text-lg font-semibold text-ice-50 mb-3">Skills</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{entry.data.skills.map((skill) => (
|
||||
<span class="rounded-lg border border-navy-800 bg-navy-900/60 px-3 py-1.5 text-xs font-mono text-ice-300/70">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Prompt section with expand/collapse -->
|
||||
<!-- Prompt section -->
|
||||
<section class="reveal reveal-d4 mb-10">
|
||||
<h2 class="font-display text-lg font-semibold text-ice-50 mb-4">Prompt</h2>
|
||||
<div class="rounded-xl border border-navy-800/60 bg-navy-900/30 p-6 relative">
|
||||
<div
|
||||
id="prompt-container"
|
||||
class="grid overflow-hidden transition-[grid-template-rows] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style="grid-template-rows: 0fr;"
|
||||
class="overflow-hidden transition-[max-height] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] max-h-[4.5em]"
|
||||
>
|
||||
<div class="min-h-0">
|
||||
<p class="font-mono text-sm leading-relaxed text-ice-300/80 whitespace-pre-wrap">{entry.data.prompt}</p>
|
||||
</div>
|
||||
<p class="font-mono text-sm leading-relaxed text-ice-300/80 whitespace-pre-wrap">{entry.data.prompt}</p>
|
||||
</div>
|
||||
<p id="prompt-preview" class="font-mono text-sm leading-relaxed text-ice-300/80 line-clamp-3">{entry.data.prompt}</p>
|
||||
<button
|
||||
id="prompt-toggle"
|
||||
class="group mt-4 flex items-center gap-2 text-sm text-ice-300/50 hover:text-teal-300 transition-colors cursor-pointer"
|
||||
|
|
@ -165,12 +125,40 @@ const langIcons: Record<string, { label: string; path: string }> = {
|
|||
</section>
|
||||
|
||||
<!-- About (rendered markdown body) -->
|
||||
<section class="reveal reveal-d5">
|
||||
<section class="reveal-left reveal-d5">
|
||||
<h2 class="font-display text-lg font-semibold text-ice-50 mb-4">About</h2>
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Prev / Next navigation -->
|
||||
{(prev || next) && (
|
||||
<nav class="reveal reveal-d5 mt-16 grid gap-4 sm:grid-cols-2 border-t border-navy-800/40 pt-8">
|
||||
{prev ? (
|
||||
<a href={`/showcase/${prev.id}`} class="group flex items-start gap-3 rounded-lg border border-navy-800/60 bg-navy-900/30 p-4 transition-all hover:border-teal-700/40">
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0 text-ice-300/40 transition-transform group-hover:-translate-x-1 group-hover:text-teal-300" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
||||
<polyline points="15,18 9,12 15,6" />
|
||||
</svg>
|
||||
<div>
|
||||
<span class="text-xs text-ice-300/40">Previous</span>
|
||||
<span class="block font-display text-sm font-semibold text-ice-50 group-hover:text-teal-300 transition-colors">{prev.data.title}</span>
|
||||
</div>
|
||||
</a>
|
||||
) : <div />}
|
||||
{next ? (
|
||||
<a href={`/showcase/${next.id}`} class="group flex items-start gap-3 rounded-lg border border-navy-800/60 bg-navy-900/30 p-4 transition-all hover:border-teal-700/40 text-right sm:flex-row-reverse">
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0 text-ice-300/40 transition-transform group-hover:translate-x-1 group-hover:text-teal-300" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
||||
<polyline points="9,6 15,12 9,18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span class="text-xs text-ice-300/40">Next</span>
|
||||
<span class="block font-display text-sm font-semibold text-ice-50 group-hover:text-teal-300 transition-colors">{next.data.title}</span>
|
||||
</div>
|
||||
</a>
|
||||
) : <div />}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
|
|
@ -179,19 +167,16 @@ const langIcons: Record<string, { label: string; path: string }> = {
|
|||
<PageScripts />
|
||||
|
||||
<script>
|
||||
// Prompt expand/collapse toggle
|
||||
const toggle = document.getElementById("prompt-toggle");
|
||||
const container = document.getElementById("prompt-container");
|
||||
const preview = document.getElementById("prompt-preview");
|
||||
const chevron = document.getElementById("prompt-chevron");
|
||||
const label = document.getElementById("prompt-toggle-label");
|
||||
|
||||
if (toggle && container && preview && chevron && label) {
|
||||
if (toggle && container && chevron && label) {
|
||||
toggle.addEventListener("click", () => {
|
||||
const isExpanded = toggle.getAttribute("aria-expanded") === "true";
|
||||
toggle.setAttribute("aria-expanded", String(!isExpanded));
|
||||
container.style.gridTemplateRows = isExpanded ? "0fr" : "1fr";
|
||||
preview.style.display = isExpanded ? "" : "none";
|
||||
container.style.maxHeight = isExpanded ? "4.5em" : `${container.scrollHeight}px`;
|
||||
chevron.style.transform = isExpanded ? "" : "rotate(180deg)";
|
||||
label.textContent = isExpanded ? "Show full prompt" : "Hide prompt";
|
||||
});
|
||||
|
|
@ -200,8 +185,8 @@ const langIcons: Record<string, { label: string; path: string }> = {
|
|||
</Layout>
|
||||
|
||||
<style>
|
||||
/* Graphviz syntax coloring for workflow code block */
|
||||
.showcase-dot {
|
||||
color: var(--color-ice-300);
|
||||
.showcase-graph-hero :global(svg) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,37 +4,17 @@ import Nav from "../../components/Nav.astro";
|
|||
import Footer from "../../components/Footer.astro";
|
||||
import PageScripts from "../../components/PageScripts.astro";
|
||||
import { getCollection } from "astro:content";
|
||||
import { langIcons } from "../../lib/langIcons";
|
||||
import { renderWorkflow } from "../../lib/renderWorkflow";
|
||||
|
||||
const entries = (await getCollection("showcase")).sort(
|
||||
(a, b) => a.data.sortOrder - b.data.sortOrder
|
||||
);
|
||||
|
||||
const langIcons: Record<string, { label: string; path: string }> = {
|
||||
python: {
|
||||
label: "Python",
|
||||
path: "M12 1.5c-5.1 0-4.8 2.2-4.8 2.2l.006 2.3H12.4v.7H4.8S1.5 6.3 1.5 11.5s2.9 5 2.9 5h1.7V14.1s-.1-2.9 2.8-2.9h4.9s2.8 0 2.8-2.7V3.8s.4-2.3-4.6-2.3zm-2.7 1.3a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM12 22.5c5.1 0 4.8-2.2 4.8-2.2l-.006-2.3H11.6v-.7h7.6s3.3.4 3.3-4.8-2.9-5-2.9-5h-1.7v2.4s.1 2.9-2.8 2.9h-4.9s-2.8 0-2.8 2.7v4.7s-.4 2.3 4.6 2.3zm2.7-1.3a.9.9 0 1 1 0-1.8.9.9 0 0 1 0 1.8z",
|
||||
},
|
||||
rust: {
|
||||
label: "Rust",
|
||||
path: "M23.8 14.1l-1.2-.7a10.5 10.5 0 0 0 0-2.8l1.2-.7a.3.3 0 0 0 .1-.4 12 12 0 0 0-2.6-4.5.3.3 0 0 0-.4 0l-1.2.7a10.2 10.2 0 0 0-2.4-1.4V3a.3.3 0 0 0-.2-.3 12 12 0 0 0-5.2 0 .3.3 0 0 0-.2.3v1.3a10.2 10.2 0 0 0-2.4 1.4l-1.2-.7a.3.3 0 0 0-.4 0A12 12 0 0 0 5.1 9.5a.3.3 0 0 0 .1.4l1.2.7a10.5 10.5 0 0 0 0 2.8l-1.2.7a.3.3 0 0 0-.1.4 12 12 0 0 0 2.6 4.5.3.3 0 0 0 .4 0l1.2-.7c.7.6 1.5 1 2.4 1.4V21a.3.3 0 0 0 .2.3 12 12 0 0 0 5.2 0 .3.3 0 0 0 .2-.3v-1.3c.9-.4 1.7-.8 2.4-1.4l1.2.7a.3.3 0 0 0 .4 0 12 12 0 0 0 2.6-4.5.3.3 0 0 0-.1-.4zM12 16.5a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9z",
|
||||
},
|
||||
typescript: {
|
||||
label: "TypeScript",
|
||||
path: "M1.5 1.5h21v21h-21V1.5zm10.2 10.2v-1.8h6.6v1.8h-2.3v6.6h-2v-6.6h-2.3zm-4.1-1.8h2v4.5c0 .7.1 1.2.3 1.5.4.5 1 .8 1.8.8s1.4-.3 1.8-.8c.2-.3.3-.8.3-1.5V9.9h2v4.7c0 1-.2 1.8-.7 2.4-.7.9-1.8 1.3-3.4 1.3s-2.7-.4-3.4-1.3c-.5-.6-.7-1.4-.7-2.4V9.9z",
|
||||
},
|
||||
ruby: {
|
||||
label: "Ruby",
|
||||
path: "M20.6 18.3L21.7 5l-5.5 1.5-2.5-3.5-3 3.2L4.3 3 3 10.5l3.3 2-3 4.2 5.3.8 1.5 4 4.5-2.8 4 3.6 2-4z",
|
||||
},
|
||||
};
|
||||
|
||||
const gradients = [
|
||||
"from-teal-500/20 to-cyan-500/10",
|
||||
"from-violet-500/20 to-fuchsia-500/10",
|
||||
"from-amber-500/20 to-orange-500/10",
|
||||
"from-emerald-500/20 to-teal-500/10",
|
||||
"from-rose-500/20 to-pink-500/10",
|
||||
];
|
||||
const workflowSvgs = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
workflowSvgs.set(entry.id, await renderWorkflow(entry.data.workflow));
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title="Showcase — Fabro" description="Projects and recipes built with Fabro workflows.">
|
||||
|
|
@ -65,11 +45,9 @@ const gradients = [
|
|||
href={`/showcase/${entry.id}`}
|
||||
class={`reveal reveal-d${Math.min(i + 2, 5)} glow-card group block rounded-xl border border-navy-800/60 bg-navy-900/30 overflow-hidden transition-all duration-400 hover:border-teal-700/40 hover:shadow-[0_0_40px_-8px_rgba(53,127,158,0.12)]`}
|
||||
>
|
||||
{/* Gradient thumbnail area */}
|
||||
<div class={`relative h-32 bg-gradient-to-br ${gradients[i % gradients.length]} flex items-center justify-center`}>
|
||||
<span class="font-display text-5xl font-bold text-white/20 select-none">
|
||||
{entry.data.title.charAt(0)}
|
||||
</span>
|
||||
{/* Workflow graph thumbnail */}
|
||||
<div class="relative h-36 bg-navy-950/80 flex items-center justify-center p-4 overflow-hidden">
|
||||
<div class="showcase-graph-thumb w-full h-full flex items-center justify-center opacity-60 group-hover:opacity-90 transition-opacity duration-400" set:html={workflowSvgs.get(entry.id)} />
|
||||
<div class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-teal-500/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
|
|
@ -116,3 +94,12 @@ const gradients = [
|
|||
<Footer />
|
||||
<PageScripts />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.showcase-graph-thumb :global(svg) {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -43,6 +43,7 @@
|
|||
"dependencies": {
|
||||
"@astrojs/react": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@viz-js/viz": "^3.25.0",
|
||||
"astro": "^5.9.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
|
@ -1338,6 +1339,8 @@
|
|||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"marketing/@viz-js/viz": ["@viz-js/viz@3.25.0", "", {}, "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw=="],
|
||||
|
||||
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"morgan/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue