Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
---
/**
* Custom Portable Text renderer for marketing blocks.
*
* This component maps custom block types (marketing.hero, marketing.features, etc.)
* to their corresponding Astro components. Pass it to the PortableText component
* via the `components` prop.
*/
import type { PortableTextBlock } from "emdash";
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";
import Testimonials from "./blocks/Testimonials.astro";
import Pricing from "./blocks/Pricing.astro";
import FAQ from "./blocks/FAQ.astro";
interface Props {
value: PortableTextBlock[];
}
const { value } = Astro.props;
// Custom block type components
const marketingTypes = {
"marketing.hero": Hero,
"marketing.features": Features,
"marketing.testimonials": Testimonials,
"marketing.pricing": Pricing,
"marketing.faq": FAQ,
};
---
<PortableText value={value} components={{ type: marketingTypes }} />

View File

@@ -0,0 +1,114 @@
---
interface Props {
node: {
_key?: string;
headline?: string;
items: Array<{
question: string;
answer: string;
}>;
};
}
const { node } = Astro.props;
const { _key, headline, items } = node;
---
<section class="faq section" id={_key}>
<div class="container">
{headline && (
<header class="faq-header">
<h2 class="faq-headline">{headline}</h2>
</header>
)}
<div class="faq-list">
{items.map((item) => (
<details class="faq-item" name="faq">
<summary class="faq-question">
<span>{item.question}</span>
<svg class="faq-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6" />
</svg>
</summary>
<div class="faq-answer">
<p>{item.answer}</p>
</div>
</details>
))}
</div>
</div>
</section>
<style>
.faq-header {
text-align: center;
margin-bottom: var(--spacing-4xl);
}
.faq-headline {
font-size: var(--font-size-3xl);
font-weight: 800;
}
.faq-list {
max-width: var(--max-width);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.faq-item {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
transition: border-color var(--transition-fast);
}
.faq-item:hover {
border-color: var(--color-muted);
}
.faq-item[open] {
border-color: var(--color-primary-light);
}
.faq-question {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
padding: var(--spacing-lg);
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
list-style: none;
}
.faq-question::-webkit-details-marker {
display: none;
}
.faq-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--color-muted);
transition: transform var(--transition-base);
}
.faq-item[open] .faq-icon {
transform: rotate(180deg);
}
.faq-answer {
padding: 0 var(--spacing-lg) var(--spacing-lg);
}
.faq-answer p {
font-size: var(--font-size-sm);
color: var(--color-muted);
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,135 @@
---
import { Icon } from "astro-iconset/components";
interface Props {
node: {
_key?: string;
headline?: string;
subheadline?: string;
features: Array<{
icon: string;
title: string;
description: string;
}>;
};
}
const { node } = Astro.props;
const { _key, headline, subheadline, features } = node;
const iconMap: Record<string, string> = {
zap: "ph:lightning",
shield: "ph:shield-check",
users: "ph:users-three",
chart: "ph:chart-bar",
code: "ph:code",
globe: "ph:globe",
heart: "ph:heart",
star: "ph:star",
check: "ph:check-circle",
lock: "ph:lock",
clock: "ph:clock",
cloud: "ph:cloud",
};
---
<section class="features section" id={_key}>
<div class="container">
{(headline || subheadline) && (
<header class="features-header">
{headline && <h2 class="features-headline">{headline}</h2>}
{subheadline && <p class="features-subheadline">{subheadline}</p>}
</header>
)}
<div class="features-grid">
{features.map((feature) => (
<div class="feature-card">
<div class="feature-icon">
<Icon name={iconMap[feature.icon] || "ph:sparkle"} aria-hidden="true" />
</div>
<h3 class="feature-title">{feature.title}</h3>
<p class="feature-description">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
<style>
.features-header {
text-align: center;
max-width: var(--max-width);
margin: 0 auto var(--spacing-4xl);
}
.features-headline {
font-size: var(--font-size-3xl);
font-weight: 800;
margin-bottom: var(--spacing-md);
}
.features-subheadline {
font-size: var(--font-size-lg);
color: var(--color-muted);
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-xl);
}
.feature-card {
padding: var(--spacing-xl);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
transition:
transform var(--transition-base),
box-shadow var(--transition-base),
border-color var(--transition-base);
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--color-primary-light);
}
.feature-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
border-radius: var(--radius);
margin-bottom: var(--spacing-lg);
}
.feature-title {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: var(--spacing-sm);
}
.feature-description {
font-size: var(--font-size-sm);
color: var(--color-muted);
line-height: 1.6;
}
@media (max-width: 900px) {
.features-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.features-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,180 @@
---
interface Props {
node: {
headline: string;
subheadline?: string;
// CTAs are flattened to sibling fields because Block Kit has no
// object-group element. Empty strings mean "no CTA".
primaryCtaLabel?: string;
primaryCtaUrl?: string;
secondaryCtaLabel?: string;
secondaryCtaUrl?: string;
image?: { url: string; alt?: string };
centered?: boolean;
};
}
const { node } = Astro.props;
const { headline, subheadline, image, centered } = node;
const primaryCta =
node.primaryCtaLabel && node.primaryCtaUrl
? { label: node.primaryCtaLabel, url: node.primaryCtaUrl }
: undefined;
const secondaryCta =
node.secondaryCtaLabel && node.secondaryCtaUrl
? { label: node.secondaryCtaLabel, url: node.secondaryCtaUrl }
: undefined;
---
<section class:list={["hero", { "hero-centered": centered, "hero-with-image": !!image }]}>
<div class="hero-content">
<h1 class="hero-headline">{headline}</h1>
{subheadline && <p class="hero-subheadline">{subheadline}</p>}
{(primaryCta || secondaryCta) && (
<div class="hero-actions">
{primaryCta && (
<a href={primaryCta.url} class="btn btn-primary btn-lg">
{primaryCta.label}
</a>
)}
{secondaryCta && (
<a href={secondaryCta.url} class="btn btn-secondary btn-lg">
{secondaryCta.label}
</a>
)}
</div>
)}
</div>
{image ? (
<div class="hero-image">
<img src={image.url} alt={image.alt || ""} loading="eager" />
</div>
) : !centered && (
<div class="hero-visual" aria-hidden="true">
<img src="/hero-visual.svg" alt="" width="800" height="800" />
</div>
)}
</section>
<style>
.hero {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-4xl) var(--spacing-lg);
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-2xl);
align-items: center;
}
.hero-centered {
grid-template-columns: 1fr;
text-align: center;
max-width: var(--max-width);
padding-top: var(--spacing-4xl);
padding-bottom: var(--spacing-xl);
}
.hero-centered .hero-content {
max-width: 100%;
}
.hero-centered .hero-actions {
justify-content: center;
}
.hero-content {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
max-width: 560px;
}
.hero-headline {
font-size: var(--font-size-5xl);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.03em;
background: linear-gradient(135deg, var(--color-text) 0%, var(--color-muted) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subheadline {
font-size: var(--font-size-xl);
line-height: 1.6;
color: var(--color-muted);
}
.hero-actions {
display: flex;
gap: var(--spacing-md);
flex-wrap: wrap;
}
.hero-image {
position: relative;
}
.hero-image img {
width: 100%;
height: auto;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
}
.hero-image::before {
content: "";
position: absolute;
inset: -10px;
background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-accent-light) 100%);
border-radius: var(--radius-lg);
z-index: -1;
opacity: 0.3;
filter: blur(20px);
}
/* Hero visual (external SVG image) */
.hero-visual {
position: relative;
width: 100%;
max-width: 550px;
justify-self: center;
}
.hero-visual img {
width: 100%;
height: auto;
}
@media (max-width: 1024px) {
.hero {
grid-template-columns: 1fr;
padding: var(--spacing-2xl) var(--spacing-lg);
gap: var(--spacing-2xl);
}
.hero-headline {
font-size: var(--font-size-4xl);
}
.hero-subheadline {
font-size: var(--font-size-lg);
}
.hero-with-image {
text-align: center;
}
.hero-with-image .hero-actions {
justify-content: center;
}
.hero-image,
.hero-visual {
order: -1;
}
}
</style>

View File

@@ -0,0 +1,206 @@
---
interface RawPlan {
name: string;
price: string;
period?: string;
description?: string;
// Features are stored as a newline-separated string (Block Kit has no
// list-of-strings element, so a multiline text input is the closest fit).
features?: string;
// CTAs are flattened to sibling fields because Block Kit has no
// object-group element.
ctaLabel?: string;
ctaUrl?: string;
highlighted?: boolean;
}
interface Props {
node: {
headline?: string;
plans: RawPlan[];
};
}
const { node } = Astro.props;
const { headline, plans: rawPlans } = node;
const plans = rawPlans.map((plan) => ({
...plan,
features: (plan.features ?? "")
.split("\n")
.map((s) => s.trim())
.filter(Boolean),
cta: {
label: plan.ctaLabel ?? "",
url: plan.ctaUrl ?? "#",
},
}));
---
<section class="pricing section">
<div class="container">
{headline && (
<header class="pricing-header">
<h2 class="pricing-headline">{headline}</h2>
</header>
)}
<div class="pricing-grid">
{plans.map((plan) => (
<div class:list={["pricing-card", { "pricing-highlighted": plan.highlighted }]}>
{plan.highlighted && <div class="pricing-badge">Most popular</div>}
<div class="pricing-plan-header">
<h3 class="pricing-name">{plan.name}</h3>
<div class="pricing-price">
<span class="pricing-amount">{plan.price}</span>
{plan.period && <span class="pricing-period">{plan.period}</span>}
</div>
{plan.description && (
<p class="pricing-description">{plan.description}</p>
)}
</div>
<ul class="pricing-features">
{plan.features.map((feature) => (
<li>
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
{feature}
</li>
))}
</ul>
<a
href={plan.cta.url}
class:list={["btn", "btn-lg", "pricing-cta", { "btn-primary": plan.highlighted, "btn-secondary": !plan.highlighted }]}
>
{plan.cta.label}
</a>
</div>
))}
</div>
</div>
</section>
<style>
.pricing-header {
text-align: center;
margin-bottom: var(--spacing-2xl);
}
.pricing-headline {
font-size: var(--font-size-3xl);
font-weight: 800;
}
.pricing-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-xl);
align-items: start;
}
.pricing-card {
position: relative;
padding: var(--spacing-xl);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.pricing-highlighted {
background: var(--color-bg);
border-color: var(--color-primary);
box-shadow: var(--shadow-xl);
transform: scale(1.02);
}
.pricing-badge {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--font-size-xs);
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
border-radius: var(--radius-full);
}
.pricing-plan-header {
text-align: center;
padding-bottom: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
}
.pricing-name {
font-size: var(--font-size-lg);
font-weight: 700;
margin-bottom: var(--spacing-sm);
}
.pricing-price {
display: flex;
align-items: baseline;
justify-content: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-sm);
}
.pricing-amount {
font-size: var(--font-size-4xl);
font-weight: 800;
letter-spacing: -0.03em;
}
.pricing-period {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.pricing-description {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.pricing-features {
list-style: none;
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.pricing-features li {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
font-size: var(--font-size-sm);
}
.check-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--color-success);
margin-top: 2px;
}
.pricing-cta {
width: 100%;
}
@media (max-width: 900px) {
.pricing-grid {
grid-template-columns: 1fr;
max-width: 400px;
margin: 0 auto;
}
.pricing-highlighted {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,135 @@
---
interface Props {
node: {
headline?: string;
testimonials: Array<{
quote: string;
author: string;
role?: string;
company?: string;
avatar?: string;
}>;
};
}
const { node } = Astro.props;
const { headline, testimonials } = node;
---
<section class="testimonials section">
<div class="container">
{headline && (
<header class="testimonials-header">
<h2 class="testimonials-headline">{headline}</h2>
</header>
)}
<div class="testimonials-grid">
{testimonials.map((testimonial) => (
<div class="testimonial-card">
<blockquote class="testimonial-quote">
"{testimonial.quote}"
</blockquote>
<div class="testimonial-author">
{testimonial.avatar && (
<img
src={testimonial.avatar}
alt={testimonial.author}
class="testimonial-avatar"
loading="lazy"
/>
)}
<div class="testimonial-info">
<span class="testimonial-name">{testimonial.author}</span>
{(testimonial.role || testimonial.company) && (
<span class="testimonial-role">
{testimonial.role}
{testimonial.role && testimonial.company && " at "}
{testimonial.company}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
</section>
<style>
.testimonials {
background: var(--color-surface);
}
.testimonials-header {
text-align: center;
margin-bottom: var(--spacing-4xl);
}
.testimonials-headline {
font-size: var(--font-size-3xl);
font-weight: 800;
}
.testimonials-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-xl);
}
.testimonial-card {
padding: var(--spacing-xl);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.testimonial-quote {
font-size: var(--font-size-lg);
line-height: 1.6;
color: var(--color-text);
flex: 1;
}
.testimonial-author {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.testimonial-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
object-fit: cover;
}
.testimonial-info {
display: flex;
flex-direction: column;
}
.testimonial-name {
font-weight: 600;
font-size: var(--font-size-sm);
}
.testimonial-role {
font-size: var(--font-size-xs);
color: var(--color-muted);
}
@media (max-width: 900px) {
.testimonials-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.testimonials-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,5 @@
export { default as Hero } from "./Hero.astro";
export { default as Features } from "./Features.astro";
export { default as Testimonials } from "./Testimonials.astro";
export { default as Pricing } from "./Pricing.astro";
export { default as FAQ } from "./FAQ.astro";

View File

@@ -0,0 +1,691 @@
---
import { getMenu, getSiteSettings } from "emdash";
import { EmDashHead } from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import { Font } from "astro:assets";
import "../styles/theme.css";
interface Props {
title?: string;
description?: string;
image?: string;
}
const { title, description, image } = Astro.props;
const settings = await getSiteSettings();
const siteTitle = settings?.title || "Acme";
const fullTitle = title ? `${title} — ${siteTitle}` : siteTitle;
const siteDescription =
settings?.tagline || "Build products people actually want";
const siteLogo = (settings?.logo as any)?.url ? settings.logo as
{ mediaId: string; alt?: string; url: string } : null;
const menu = await getMenu("primary");
const pageCtx = createPublicPageContext({
Astro,
kind: "custom",
pageType: "website",
title: fullTitle,
pageTitle: title ?? siteTitle,
description: description || siteDescription,
canonical: Astro.url.href,
image,
seo: { ogImage: image },
siteName: siteTitle,
});
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{fullTitle}</title>
<EmDashHead page={pageCtx} />
<Font cssVariable="--font-sans" preload />
<script is:inline>
// Apply theme immediately to prevent flash
(function () {
var c = document.cookie;
var i = c.indexOf("theme=");
var theme = i >= 0 ? c.slice(i + 6).split(";")[0] : null;
if (theme === "dark" || theme === "light") {
document.documentElement.classList.add(theme);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.classList.add("dark");
}
})();
</script>
</head>
<body>
<header class="site-header">
<nav class="nav">
<a href="/" class="site-logo">
{siteLogo
? <img src={siteLogo.url} alt={siteLogo.alt || siteTitle} class="site-logo-img" />
: siteTitle
}
</a>
<div class="nav-links">
{
menu?.items.map((item) => (
<a href={item.url} target={item.target}>
{item.label}
</a>
))
}
</div>
<div class="nav-actions">
<a href="/_emdash/admin" class="nav-admin">Admin</a>
<a href="/signup" class="nav-cta">Get Started</a>
</div>
</nav>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="footer-content">
<div class="footer-brand">
<span class="footer-logo">
{siteLogo
? <img src={siteLogo.url} alt={siteLogo.alt || siteTitle} class="footer-logo-img" />
: siteTitle
}
</span>
<p class="footer-tagline">
{settings?.tagline || "Build something amazing"}
</p>
</div>
<div class="footer-links">
<div class="footer-col">
<h4>Product</h4>
<a href="/#features">Features</a>
<a href="/pricing">Pricing</a>
<a href="/changelog">Changelog</a>
</div>
<div class="footer-col">
<h4>Company</h4>
<a href="/about">About</a>
<a href="/blog">Blog</a>
<a href="/careers">Careers</a>
</div>
<div class="footer-col">
<h4>Support</h4>
<a href="/docs">Documentation</a>
<a href="/contact">Contact</a>
<a href="/status">Status</a>
</div>
</div>
</div>
<div class="footer-bottom">
<p>
&copy; {new Date().getFullYear()}
{siteTitle}. All rights reserved.
</p>
<div class="theme-switcher">
<button
type="button"
class="theme-btn"
data-theme="light"
aria-label="Light mode">Light</button
>
<button
type="button"
class="theme-btn"
data-theme="dark"
aria-label="Dark mode">Dark</button
>
<button
type="button"
class="theme-btn"
data-theme="system"
aria-label="System theme">System</button
>
</div>
<p class="footer-powered">
Powered by <a href="https://emdashcms.com">EmDash</a>
</p>
</div>
</footer>
<script>
// Theme switcher
const THEME_REGEX = /theme=([^;]+)/;
const themeBtns =
document.querySelectorAll<HTMLButtonElement>(".theme-btn");
const root = document.documentElement;
function setTheme(theme: string) {
const secure = location.protocol === "https:" ? "; Secure" : "";
if (theme === "system") {
document.cookie = `theme=; path=/; max-age=0; SameSite=Lax${secure}`;
root.classList.remove("light", "dark");
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
root.classList.add("dark");
}
} else {
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure}`;
root.classList.remove("light", "dark");
root.classList.add(theme);
}
updateActiveBtn(theme);
}
function updateActiveBtn(theme: string) {
themeBtns.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.theme === theme);
});
}
function getStoredTheme(): string {
const match = document.cookie.match(THEME_REGEX);
return match ? match[1] : "system";
}
// Initialize - apply stored theme on load
const storedTheme = getStoredTheme();
setTheme(storedTheme);
themeBtns.forEach((btn) => {
btn.addEventListener("click", () => {
setTheme(btn.dataset.theme || "system");
});
});
// Listen for system preference changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
if (getStoredTheme() === "system") {
root.classList.toggle("dark", e.matches);
}
});
</script>
<style is:global>
/*
* Establish layer order: "base" has lower priority than unlayered
* styles, so theme.css overrides always win regardless of source
* order in the bundled CSS.
*/
@layer base;
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
/* Colors - Playful/Bold palette */
--color-bg: #ffffff;
--color-text: #0f172a;
--color-muted: #64748b;
--color-border: #e2e8f0;
--color-surface: #f8fafc;
--color-primary: #6366f1;
--color-primary-dark: #4f46e5;
--color-primary-light: #818cf8;
--color-accent: #f472b6;
--color-accent-light: #f9a8d4;
--color-success: #22c55e;
--color-warning: #f59e0b;
/* Typography */
--font-mono: ui-monospace, "SF Mono", monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
--font-size-5xl: 3.5rem;
--font-size-6xl: 4.5rem;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
--spacing-3xl: 4rem;
--spacing-4xl: 6rem;
--spacing-5xl: 8rem;
/* Layout */
--max-width: 720px;
--wide-width: 1200px;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 16px;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
/* Dark mode via system preference (when no explicit class) */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-muted: #94a3b8;
--color-border: #334155;
--color-surface: #1e293b;
--color-primary: #818cf8;
--color-primary-dark: #6366f1;
--color-primary-light: #a5b4fc;
}
}
/* Explicit dark mode */
:root.dark {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-muted: #94a3b8;
--color-border: #334155;
--color-surface: #1e293b;
--color-primary: #818cf8;
--color-primary-dark: #6366f1;
--color-primary-light: #a5b4fc;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
font-size: var(--font-size-base);
line-height: 1.6;
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
a {
color: currentColor;
text-decoration: none;
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.02em;
}
h1 {
font-size: var(--font-size-5xl);
}
h2 {
font-size: var(--font-size-3xl);
}
h3 {
font-size: var(--font-size-2xl);
}
h4 {
font-size: var(--font-size-xl);
}
/* Utility classes */
.container {
max-width: var(--wide-width);
margin: 0 auto;
padding: 0 var(--spacing-lg);
}
.section {
padding: var(--spacing-5xl) 0;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-lg);
font-family: inherit;
font-size: var(--font-size-sm);
font-weight: 600;
border-radius: var(--radius);
cursor: pointer;
transition:
background var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.btn:hover {
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
color: white;
background: linear-gradient(
135deg,
var(--color-primary-dark),
var(--color-accent)
);
border: none;
transition:
background 0.3s ease,
box-shadow 0.3s ease;
}
.btn-primary:hover {
background: linear-gradient(
135deg,
var(--color-primary),
var(--color-accent)
);
box-shadow: var(--shadow-lg);
}
.btn-secondary {
color: var(--color-text);
background: transparent;
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--color-surface);
border-color: var(--color-muted);
}
.btn-lg {
padding: var(--spacing-md) var(--spacing-xl);
font-size: var(--font-size-base);
}
}
</style>
<style>
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.nav {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-md) var(--spacing-lg);
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--spacing-lg);
}
.site-logo {
font-size: var(--font-size-xl);
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(
135deg,
var(--color-primary),
var(--color-accent)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.site-logo-img {
height: 48px;
width: auto;
display: block;
margin: -8px 0;
}
.nav-links {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-lg);
margin-left: auto;
}
.nav-links a {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-muted);
transition: color var(--transition-fast);
}
.nav-links a:hover {
color: var(--color-text);
}
.nav-actions {
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.nav-admin {
font-size: var(--font-size-sm);
color: var(--color-muted);
opacity: 0.5;
}
.nav-cta {
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-sm);
font-weight: 600;
color: white;
background: linear-gradient(
135deg,
var(--color-primary-dark),
var(--color-accent)
);
border-radius: var(--radius-sm);
transition:
background 0.3s ease,
transform var(--transition-fast);
}
.nav-cta:hover {
background: linear-gradient(
135deg,
var(--color-primary),
var(--color-accent)
);
transform: translateY(-1px);
}
main {
min-height: calc(100vh - 200px);
}
.site-footer {
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
.footer-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-4xl) var(--spacing-lg) var(--spacing-2xl);
display: grid;
grid-template-columns: 1fr 2fr;
gap: var(--spacing-4xl);
}
.footer-logo {
font-size: var(--font-size-xl);
font-weight: 800;
letter-spacing: -0.03em;
}
.footer-logo-img {
height: 24px;
width: auto;
}
.footer-tagline {
margin-top: var(--spacing-sm);
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.footer-links {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-xl);
}
.footer-col h4 {
font-size: var(--font-size-sm);
font-weight: 600;
margin-bottom: var(--spacing-md);
}
.footer-col a {
display: block;
font-size: var(--font-size-sm);
color: var(--color-muted);
padding: var(--spacing-xs) 0;
transition: color var(--transition-fast);
}
.footer-col a:hover {
color: var(--color-text);
}
.footer-bottom {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-lg);
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-sm);
color: var(--color-muted);
border-top: 1px solid var(--color-border);
}
.footer-powered a {
color: var(--color-text);
}
.theme-switcher {
display: flex;
gap: var(--spacing-xs);
}
.theme-btn {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-muted);
font-family: var(--font-sans);
font-size: var(--font-size-sm);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.theme-btn:hover {
color: var(--color-text);
border-color: var(--color-text);
}
.theme-btn.active {
background: var(--color-bg);
color: var(--color-text);
border-color: var(--color-primary);
}
@media (max-width: 768px) {
.footer-content {
grid-template-columns: 1fr;
gap: var(--spacing-2xl);
}
.footer-links {
grid-template-columns: repeat(2, 1fr);
}
.footer-bottom {
flex-direction: column;
gap: var(--spacing-md);
text-align: center;
}
.theme-switcher {
order: -1;
}
h1 {
font-size: var(--font-size-4xl);
}
h2 {
font-size: var(--font-size-2xl);
}
}
@media (max-width: 540px) {
.nav {
flex-wrap: wrap;
row-gap: var(--spacing-md);
justify-content: space-between;
}
.nav-links {
order: 3;
width: 100%;
justify-content: flex-end;
margin-left: 0;
}
}
@media (max-width: 480px) {
.footer-links {
grid-template-columns: 1fr;
}
}
</style>
</body>
</html>

View File

@@ -0,0 +1,13 @@
/**
* EmDash Live Content Collections
*
* Defines the _emdash collection that handles all content types from the database.
* Query specific types using getEmDashCollection() and getEmDashEntry().
*/
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

View File

@@ -0,0 +1,69 @@
---
import Base from "../layouts/Base.astro";
---
<Base title="Page not found">
<div class="not-found">
<div class="not-found-code">404</div>
<h1>Page not found</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<div class="not-found-actions">
<a href="/" class="btn btn-primary btn-lg">Go home</a>
<a href="/contact" class="btn btn-secondary btn-lg">Contact us</a>
</div>
</div>
</Base>
<style>
.not-found {
text-align: center;
padding: var(--spacing-5xl) var(--spacing-lg);
max-width: var(--max-width);
margin: 0 auto;
}
.not-found-code {
font-size: 10rem;
font-weight: 800;
line-height: 1;
letter-spacing: -0.05em;
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
opacity: 0.3;
margin-bottom: var(--spacing-lg);
}
.not-found h1 {
font-size: var(--font-size-3xl);
margin-bottom: var(--spacing-md);
}
.not-found p {
font-size: var(--font-size-lg);
color: var(--color-muted);
margin-bottom: var(--spacing-2xl);
}
.not-found-actions {
display: flex;
gap: var(--spacing-md);
justify-content: center;
flex-wrap: wrap;
}
@media (max-width: 480px) {
.not-found-code {
font-size: 6rem;
}
.not-found-actions {
flex-direction: column;
}
.not-found-actions .btn {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,361 @@
---
import { Icon } from "astro-iconset/components";
import { getEmDashEntry } from "emdash";
import Base from "../layouts/Base.astro";
import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page, cacheHint } = await getEmDashEntry("pages", "contact");
// `cache.set` only applies to GET responses; wrap defensively because
// this route also handles POST submissions where the cache API may
// reject the call.
try {
Astro.cache.set(cacheHint);
} catch {}
// Handle form submission
// NOTE: This is demo code. For production, add:
// - CSRF token validation
// - Rate limiting (e.g., via Cloudflare or middleware)
// - Actual email sending or webhook integration
let formStatus: "idle" | "success" | "error" = "idle";
let formMessage = "";
if (Astro.request.method === "POST") {
try {
const formData = await Astro.request.formData();
const name = formData.get("name")?.toString() || "";
const email = formData.get("email")?.toString() || "";
const company = formData.get("company")?.toString() || "";
const message = formData.get("message")?.toString() || "";
if (!name || !email || !message) {
formStatus = "error";
formMessage = "Please fill in all required fields.";
} else if (!email.includes("@")) {
formStatus = "error";
formMessage = "Please enter a valid email address.";
} else {
// TODO: Replace with actual email/webhook integration
console.log("Contact form submission:", {
name,
email,
company,
message,
});
formStatus = "success";
formMessage =
"Thanks for reaching out! We'll get back to you within 24 hours.";
}
} catch {
formStatus = "error";
formMessage = "Something went wrong. Please try again.";
}
}
const pageContent = page?.data.content;
---
<Base
title="Contact"
description="Have questions? Want a demo? We'd love to hear from you."
>
{pageContent && <MarketingBlocks value={pageContent} />}
<section class="contact-form-section section">
<div class="container">
<div class="contact-grid">
<div class="contact-info">
<h2>Talk to our team</h2>
<p>Fill out the form and we'll be in touch within 24 hours.</p>
<div class="contact-methods">
<div class="contact-method">
<div class="contact-icon">
<Icon name="ph:envelope" aria-hidden="true" />
</div>
<div class="contact-method-content">
<h4>Email</h4>
<a href="mailto:hello@acme.example">hello@acme.example</a>
</div>
</div>
<div class="contact-method">
<div class="contact-icon">
<Icon name="ph:lifebuoy" aria-hidden="true" />
</div>
<div class="contact-method-content">
<h4>Support</h4>
<a href="mailto:support@acme.example">support@acme.example</a>
</div>
</div>
<div class="contact-method">
<div class="contact-icon">
<Icon name="ph:currency-dollar" aria-hidden="true" />
</div>
<div class="contact-method-content">
<h4>Sales</h4>
<a href="mailto:sales@acme.example">sales@acme.example</a>
</div>
</div>
</div>
</div>
<div class="contact-form-wrapper">
{
formStatus === "success" ? (
<div class="form-success">
<div class="success-icon">✓</div>
<h3>Message Sent!</h3>
<p>{formMessage}</p>
<a href="/contact" class="btn btn-secondary">
Send another message
</a>
</div>
) : (
<form method="POST" class="contact-form">
{formStatus === "error" && (
<div class="form-error">
<p>{formMessage}</p>
</div>
)}
<div class="form-row">
<div class="form-field">
<label for="name">Name *</label>
<input
type="text"
id="name"
name="name"
required
placeholder="Your name"
autocomplete="name"
/>
</div>
<div class="form-field">
<label for="email">Email *</label>
<input
type="email"
id="email"
name="email"
required
placeholder="you@company.com"
autocomplete="email"
/>
</div>
</div>
<div class="form-field">
<label for="company">Company</label>
<input
type="text"
id="company"
name="company"
placeholder="Your company"
autocomplete="organization"
/>
</div>
<div class="form-field">
<label for="message">Message *</label>
<textarea
id="message"
name="message"
required
rows="5"
placeholder="Tell us about your project or question..."
/>
</div>
<button type="submit" class="btn btn-primary btn-lg">
Send Message
</button>
</form>
)
}
</div>
</div>
</div>
</section>
</Base>
<style>
.contact-form-section {
padding-bottom: var(--spacing-5xl);
}
.contact-grid {
display: grid;
grid-template-columns: 1fr 1.5fr;
gap: var(--spacing-4xl);
align-items: start;
}
.contact-info h2 {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-md);
}
.contact-info > p {
color: var(--color-muted);
margin-bottom: var(--spacing-2xl);
}
.contact-methods {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.contact-method {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.contact-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
color: white;
background: linear-gradient(
135deg,
var(--color-primary),
var(--color-accent)
);
border-radius: var(--radius);
flex-shrink: 0;
}
.contact-method-content h4 {
font-size: var(--font-size-sm);
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.contact-method-content a {
color: var(--color-primary);
font-size: var(--font-size-lg);
}
.contact-method-content a:hover {
text-decoration: underline;
}
.contact-form-wrapper {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-2xl);
}
.contact-form {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-lg);
}
.form-field {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.form-field label {
font-size: var(--font-size-sm);
font-weight: 500;
}
.form-field input,
.form-field textarea {
padding: var(--spacing-md);
font-family: inherit;
font-size: var(--font-size-base);
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.form-field input:focus,
.form-field textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.form-field input::placeholder,
.form-field textarea::placeholder {
color: var(--color-muted);
}
.form-field textarea {
resize: vertical;
min-height: 120px;
}
.form-error {
padding: var(--spacing-md);
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
color: #dc2626;
font-size: var(--font-size-sm);
}
@media (prefers-color-scheme: dark) {
.form-error {
color: #f87171;
}
}
.form-success {
text-align: center;
padding: var(--spacing-2xl);
}
.success-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: white;
background: var(--color-success);
border-radius: var(--radius-full);
margin: 0 auto var(--spacing-lg);
}
.form-success h3 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-sm);
}
.form-success p {
color: var(--color-muted);
margin-bottom: var(--spacing-xl);
}
@media (max-width: 768px) {
.contact-grid {
grid-template-columns: 1fr;
gap: var(--spacing-2xl);
}
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,49 @@
---
import { getEmDashEntry } from "emdash";
import Base from "../layouts/Base.astro";
import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page, cacheHint } = await getEmDashEntry("pages", "home");
Astro.cache.set(cacheHint);
const pageTitle = page?.data.title;
const pageContent = page?.data.content;
---
<Base
title={pageTitle !== "Home" ? pageTitle : undefined}
description="Build products people actually want. The all-in-one platform for modern teams."
>
{
pageContent ? (
<MarketingBlocks value={pageContent} />
) : (
<div class="empty-state">
<h1>Welcome to Acme</h1>
<p>Edit the home page content in the admin to get started.</p>
<a href="/_emdash/admin" class="btn btn-primary">
Open Admin
</a>
</div>
)
}
</Base>
<style>
.empty-state {
text-align: center;
padding: var(--spacing-5xl) var(--spacing-lg);
max-width: var(--max-width);
margin: 0 auto;
}
.empty-state h1 {
margin-bottom: var(--spacing-md);
}
.empty-state p {
color: var(--color-muted);
margin-bottom: var(--spacing-xl);
}
</style>

View File

@@ -0,0 +1,48 @@
---
import { getEmDashEntry } from "emdash";
import Base from "../layouts/Base.astro";
import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page, cacheHint } = await getEmDashEntry("pages", "pricing");
Astro.cache.set(cacheHint);
const pageContent = page?.data.content;
---
<Base
title="Pricing"
description="Simple, transparent pricing. No hidden fees. No surprises."
>
{
pageContent ? (
<MarketingBlocks value={pageContent} />
) : (
<div class="empty-state">
<h1>Pricing</h1>
<p>Add pricing content in the admin.</p>
<a href="/_emdash/admin" class="btn btn-primary">
Open Admin
</a>
</div>
)
}
</Base>
<style>
.empty-state {
text-align: center;
padding: var(--spacing-5xl) var(--spacing-lg);
max-width: var(--max-width);
margin: 0 auto;
}
.empty-state h1 {
margin-bottom: var(--spacing-md);
}
.empty-state p {
color: var(--color-muted);
margin-bottom: var(--spacing-xl);
}
</style>

View File

@@ -0,0 +1,218 @@
/**
* Marketing blocks plugin (inline, template-local).
*
* Registers the five marketing block types so editors can insert and edit them
* in the admin's Portable Text editor. Block Kit `fields` describe the form
* shown when inserting or editing a block.
*
* Constraints worth knowing:
*
* - Block Kit has no "object group" element, so nested object shapes (e.g. a
* CTA's { label, url }) are flattened to sibling fields like ctaLabel and
* ctaUrl. The site-side renderer reads the flat keys.
* - Repeater sub-fields are scalar only: text_input, number_input, select,
* toggle. Nested repeaters are not allowed -- list-of-strings becomes a
* single multiline text field, split on newline at render time (see
* Pricing.astro for the pattern).
* - There is no media picker element in the editor's plugin-block modal yet,
* so image fields (avatars, hero images) are URL strings entered by hand.
*
* Site-side rendering still goes through MarketingBlocks.astro --
* componentsEntry auto-wiring is a separate cleanup.
*/
import { definePlugin } from "emdash";
import type { PluginDefinition } from "emdash";
const ICON_OPTIONS = [
{ label: "Lightning", value: "zap" },
{ label: "Shield", value: "shield" },
{ label: "Users", value: "users" },
{ label: "Chart", value: "chart" },
{ label: "Code", value: "code" },
{ label: "Globe", value: "globe" },
{ label: "Heart", value: "heart" },
{ label: "Star", value: "star" },
{ label: "Check", value: "check" },
{ label: "Lock", value: "lock" },
{ label: "Clock", value: "clock" },
{ label: "Cloud", value: "cloud" },
];
const definition: PluginDefinition = {
id: "marketing-blocks",
version: "0.1.0",
admin: {
portableTextBlocks: [
{
type: "marketing.hero",
label: "Hero",
category: "Sections",
description: "Big headline section with optional CTAs",
fields: [
{ type: "text_input", action_id: "headline", label: "Headline" },
{
type: "text_input",
action_id: "subheadline",
label: "Subheadline",
multiline: true,
},
{ type: "text_input", action_id: "primaryCtaLabel", label: "Primary CTA label" },
{ type: "text_input", action_id: "primaryCtaUrl", label: "Primary CTA URL" },
{
type: "text_input",
action_id: "secondaryCtaLabel",
label: "Secondary CTA label",
},
{ type: "text_input", action_id: "secondaryCtaUrl", label: "Secondary CTA URL" },
{ type: "toggle", action_id: "centered", label: "Center the layout" },
],
},
{
type: "marketing.features",
label: "Features",
category: "Sections",
description: "Grid of feature cards with icons",
fields: [
{ type: "text_input", action_id: "headline", label: "Headline" },
{
type: "text_input",
action_id: "subheadline",
label: "Subheadline",
multiline: true,
},
{
type: "repeater",
action_id: "features",
label: "Features",
item_label: "Feature",
min_items: 1,
max_items: 12,
fields: [
{
type: "select",
action_id: "icon",
label: "Icon",
options: ICON_OPTIONS,
},
{ type: "text_input", action_id: "title", label: "Title" },
{
type: "text_input",
action_id: "description",
label: "Description",
multiline: true,
},
],
},
],
},
{
type: "marketing.testimonials",
label: "Testimonials",
category: "Sections",
description: "Customer testimonial cards",
fields: [
{ type: "text_input", action_id: "headline", label: "Headline" },
{
type: "repeater",
action_id: "testimonials",
label: "Testimonials",
item_label: "Testimonial",
min_items: 1,
fields: [
{ type: "text_input", action_id: "quote", label: "Quote", multiline: true },
{ type: "text_input", action_id: "author", label: "Author name" },
{ type: "text_input", action_id: "role", label: "Role / title" },
{ type: "text_input", action_id: "company", label: "Company" },
{ type: "text_input", action_id: "avatar", label: "Avatar URL" },
],
},
],
},
{
type: "marketing.pricing",
label: "Pricing",
category: "Sections",
description: "Pricing plan comparison cards",
fields: [
{ type: "text_input", action_id: "headline", label: "Headline" },
{
type: "repeater",
action_id: "plans",
label: "Plans",
item_label: "Plan",
min_items: 1,
max_items: 6,
fields: [
{ type: "text_input", action_id: "name", label: "Plan name" },
{
type: "text_input",
action_id: "price",
label: "Price",
placeholder: "$29 or Custom",
},
{
type: "text_input",
action_id: "period",
label: "Period",
placeholder: "/month",
},
{
type: "text_input",
action_id: "description",
label: "Description",
multiline: true,
},
{
type: "text_input",
action_id: "features",
label: "Features (one per line)",
multiline: true,
placeholder: "Unlimited projects\nPriority support\nSSO",
},
{ type: "text_input", action_id: "ctaLabel", label: "CTA label" },
{ type: "text_input", action_id: "ctaUrl", label: "CTA URL" },
{ type: "toggle", action_id: "highlighted", label: "Highlight this plan" },
],
},
],
},
{
type: "marketing.faq",
label: "FAQ",
category: "Sections",
description: "Frequently asked questions",
fields: [
{ type: "text_input", action_id: "headline", label: "Headline" },
{
type: "repeater",
action_id: "items",
label: "Questions",
item_label: "Question",
min_items: 1,
fields: [
{ type: "text_input", action_id: "question", label: "Question" },
{
type: "text_input",
action_id: "answer",
label: "Answer",
multiline: true,
},
],
},
],
},
],
},
};
export function createPlugin() {
return definePlugin(definition);
}
export default createPlugin;

View File

@@ -0,0 +1,80 @@
/*
theme.css -- override any :root variable here to retheme the site.
This is the only file you need to edit to customize the site's visual
appearance. All defaults are listed below as comments. Uncomment and
change any value to override it.
Base.astro puts its defaults inside @layer base, so declarations here
(which are unlayered) always take priority -- no specificity tricks needed.
Note: this template defines explicit dark mode colors in Base.astro.
Overriding light-mode --color-* variables here won't affect dark mode.
To customize dark mode, also override --color-* variables inside a
@media (prefers-color-scheme: dark) block and/or in the :root.dark rule.
*/
:root {
/* --- Colors ---
--color-bg: #ffffff;
--color-text: #0f172a;
--color-muted: #64748b;
--color-border: #e2e8f0;
--color-surface: #f8fafc;
--color-primary: #6366f1;
--color-primary-dark: #4f46e5;
--color-primary-light: #818cf8;
--color-accent: #f472b6;
--color-accent-light: #f9a8d4;
--color-success: #22c55e;
--color-warning: #f59e0b;
*/
/* --- Typography ---
--font-mono: ui-monospace, "SF Mono", monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
--font-size-5xl: 3.5rem;
--font-size-6xl: 4.5rem;
*/
/* --- Spacing ---
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
--spacing-3xl: 4rem;
--spacing-4xl: 6rem;
--spacing-5xl: 8rem;
*/
/* --- Layout ---
--max-width: 720px;
--wide-width: 1200px;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 16px;
--radius-full: 9999px;
*/
/* --- Transitions ---
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
*/
/* --- Shadows ---
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
*/
}

View File

@@ -0,0 +1,5 @@
import handler from "@astrojs/cloudflare/entrypoints/server";
export { PluginBridge } from "@emdash-cms/cloudflare/sandbox";
export default handler;