<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projects — Your Name</title>
<meta name="description" content="Portfolio of projects and work">
<meta name="keywords" content="rust{"sep": ", "}static site generator{"sep": ", "}github pages{"sep": ", "}ssg{"sep": ", "}content pipeline">
<link rel="icon" href="/static/media/site/root/assets/favicon.svg">
<meta property="og:title" content="Projects — Your Name">
<meta property="og:description" content="Portfolio of projects and work">
<meta property="og:type" content="website">
<meta property="og:url" content="https://matthiaskainer.github.io/ferrosite//projects/">
<link rel="canonical" href="none">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/ferrosite/assets/main.css">
<style id="pfusch-style">
:root {
--color-primary: #b45309;
--color-primary-dark: #92400e;
--color-accent: #0f766e;
--color-bg: #f6f3ec;
--color-surface: #fffdf8;
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-border: #d6d0c4;
--color-code-bg: #1f2937;
--color-success: #15803d;
--color-warning: #b45309;
--color-error: #b91c1c;
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
--font-heading: 'Space Grotesk', 'IBM Plex Sans', sans-serif;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.3rem;
--font-size-2xl: 1.8rem;
--font-size-3xl: 2.6rem;
--line-height: 1.65;
--spacing-unit: 0.25rem;
--container-max: 1080px;
--sidebar-width: 280px;
--header-height: 72px;
--dock-height: 80px;
}
</style>
<script type="module">
import { pfusch, html, css } from "https://cdn.jsdelivr.net/gh/MatthiasKainer/pfusch@main/pfusch.js";
pfusch("ferrosite-contact-form", {
endpoint: "/api/contact",
status: "", errorMsg: "",
}, (state, trigger, { children }) => [
css`
:host { display: block; }
.form { display: flex; flex-direction: column; gap: 1.25rem; }
.field { display: flex; flex-direction: column; gap: 0.4rem; }
label {
font-size: 0.875rem; font-weight: 500;
color: var(--color-text-muted);
}
input, textarea {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.65rem 1rem;
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
width: 100%;
}
input:focus, textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(14,165,233,0.15);
}
textarea { resize: vertical; min-height: 140px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 520px) { .row { grid-template-columns: 1fr; } }
.submit-btn {
align-self: flex-start;
display: inline-flex; align-items: center; gap: 0.5rem;
background: var(--color-primary); color: #fff;
border: none; border-radius: 8px;
padding: 0.7rem 1.6rem; font-size: 1rem; font-weight: 600;
font-family: var(--font-sans); cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.submit-btn:not(:disabled):hover { background: var(--color-primary-dark); }
.spinner {
width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff; border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.alert {
padding: 1rem 1.25rem; border-radius: 8px;
font-size: 0.9rem; font-weight: 500;
}
.alert-success {
background: rgba(34,197,94,0.12);
border: 1px solid rgba(34,197,94,0.3);
color: var(--color-success);
}
.alert-error {
background: rgba(239,68,68,0.12);
border: 1px solid rgba(239,68,68,0.3);
color: var(--color-error);
}
`,
state.status === "success"
? html.div({ class: "alert alert-success" },
"Message sent! I'll get back to you within a day or two.")
: null,
state.status === "error"
? html.div({ class: "alert alert-error" },
`${state.errorMsg || "Something went wrong. Please try again or email directly."}`)
: null,
state.status !== "success"
? html.form({
class: "form",
submit: async (e) => {
e.preventDefault();
if (state.status === "sending") return;
const form = e.target;
const data = Object.fromEntries(new FormData(form));
state.status = "sending";
state.errorMsg = "";
try {
const res = await fetch(state.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command: "SendMessage",
payload: data,
}),
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.error || "Request failed");
state.status = "success";
} catch (err) {
state.status = "error";
state.errorMsg = err.message;
}
},
},
html.div({ class: "row" },
html.div({ class: "field" },
html.label({ for: "cf-name" }, "Your Name"),
html.input({ type: "text", id: "cf-name", name: "name",
required: true, autocomplete: "name", placeholder: "Jane Smith",
disabled: state.status === "sending" }),
),
html.div({ class: "field" },
html.label({ for: "cf-email" }, "Email Address"),
html.input({ type: "email", id: "cf-email", name: "email",
required: true, autocomplete: "email", placeholder: "jane@example.com",
disabled: state.status === "sending" }),
),
),
html.div({ class: "field" },
html.label({ for: "cf-subject" }, "Subject (optional)"),
html.input({ type: "text", id: "cf-subject", name: "subject",
placeholder: "Project inquiry, collaboration, ...",
disabled: state.status === "sending" }),
),
html.div({ class: "field" },
html.label({ for: "cf-message" }, "Message"),
html.textarea({ id: "cf-message", name: "message",
required: true, rows: 6, placeholder: "Tell me about your project...",
disabled: state.status === "sending" }),
),
html.input({ type: "text", name: "_hp", style: "display:none",
autocomplete: "off", tabindex: "-1" }),
html.button({
type: "submit",
class: "submit-btn",
disabled: state.status === "sending",
},
state.status === "sending"
? [html.div({ class: "spinner" }), "Sending..."]
: "Send Message"
),
)
: null,
]);
</script>
</head>
<body class="page-projects">
<div class="site-shell">
<div class="site-frame">
<header class="site-header">
<div class="container header-inner">
<a href="/" class="brand" aria-label="github-page home">
<span class="brand-mark">
F
</span>
<span class="brand-copy">
<span class="brand-title">
ferrosite
</span>
<span class="brand-subtitle">Yet another cms</span>
</span>
</a>
<nav class="site-nav" aria-label="Main navigation">
<a href="/ferrosite"
class="nav-link "
>
Home
</a>
<a href="/ferrosite/projects/ferrosite/"
class="nav-link "
>
Docs
</a>
<a href="https://github.com/MatthiasKainer/ferrosite"
class="button-link primary"
target="_blank" rel="noopener noreferrer">
View on GitHub
</a>
</nav>
</div>
</header>
<main class="page-content">
<div class="container">
<header class="page-header">
<div class="section-kicker">Projects</div>
<h1 class="page-title">Product pages and docs.</h1>
<p class="page-subtitle">Keep the listing simple. Each project becomes its own permanent documentation page.</p>
</header>
<section class="project-grid">
<a class="project-card" href="https://matthiaskainer.github.io/ferrosite/projects/ferrosite/">
<div class="card-meta">active</div>
<h3>ferrosite</h3>
<p class="muted">The static site generator behind matthias-kainer.de. Designed for understandable content pipelines, HTML-first output, and lightweight deployment.</p>
<div class="stack-list">
<span class="stack-tag">Rust</span>
<span class="stack-tag">MiniJinja</span>
<span class="stack-tag">Markdown</span>
<span class="stack-tag">Cloudflare Workers</span>
</div>
</a>
</section>
</div>
</main>
<footer class="footer">
<div class="container footer-inner">
<div>
<p>Ferrosite keeps content in files, pages in templates, and deployment boring enough to trust.</p>
</div>
<div>
<p>Built with <a href="https://github.com/MatthiasKainer/ferrosite">ferrosite</a>.</p>
</div>
</div>
</footer>
</div>
</div>
</body>
</html>