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,167 @@
---
import type { MediaValue } from "emdash";
import { Image } from "emdash/ui";
interface Props {
title: string;
summary?: string;
featuredImage: MediaValue | string;
href: string;
client?: string;
year?: string;
categories?: string[];
tags?: string[];
}
const { title, summary, featuredImage, href, client, year, categories, tags } =
Astro.props;
// Combine categories and tags for display
const allTags = [...(categories || []), ...(tags || [])];
---
<article class="project-card">
<a href={href} class="card-link">
<div class="card-image">
<Image image={featuredImage} />
<div class="card-overlay">
<span class="card-view">View Project</span>
</div>
</div>
<div class="card-content">
<h2 class="card-title">{title}</h2>
<div class="card-meta">
{client && <span class="card-client">{client}</span>}
{client && year && <span class="card-separator">·</span>}
{year && <span class="card-year">{year}</span>}
</div>
{summary && <p class="card-summary">{summary}</p>}
{
allTags.length > 0 && (
<div class="card-categories">
{allTags.map((tag) => (
<span class="card-category">{tag}</span>
))}
</div>
)
}
</div>
</a>
</article>
<style>
.project-card {
position: relative;
}
.card-link {
display: flex;
flex-direction: column;
gap: var(--spacing-lg, 1.5rem);
text-decoration: none;
color: inherit;
}
.card-image {
position: relative;
overflow: hidden;
border-radius: var(--radius, 4px);
}
.card-image img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
transition: transform var(--transition-slow, 300ms ease);
}
.card-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0);
transition: background var(--transition-base, 200ms ease);
}
.card-view {
font-family: var(--font-serif, Georgia, serif);
font-size: var(--font-size-sm, 0.875rem);
color: white;
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
border: 1px solid white;
border-radius: var(--radius, 4px);
opacity: 0;
transform: translateY(10px);
transition:
opacity var(--transition-base, 200ms ease),
transform var(--transition-base, 200ms ease);
}
.project-card:hover .card-image img {
transform: scale(1.03);
}
.project-card:hover .card-overlay {
background: rgba(0, 0, 0, 0.4);
}
.project-card:hover .card-view {
opacity: 1;
transform: translateY(0);
}
.card-content {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 0.25rem);
}
.card-title {
font-family: var(--font-serif, Georgia, serif);
font-size: var(--font-size-xl, 1.5rem);
font-weight: 500;
transition: color var(--transition-fast, 150ms ease);
}
.project-card:hover .card-title {
color: var(--color-accent, #7c3aed);
}
.card-meta {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-muted, #6b7280);
}
.card-separator {
margin: 0 var(--spacing-xs, 0.25rem);
}
.card-summary {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-muted, #6b7280);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-categories {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm, 0.5rem);
margin-top: var(--spacing-sm, 0.5rem);
}
.card-category {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted, #6b7280);
padding: var(--spacing-xs, 0.25rem) var(--spacing-sm, 0.5rem);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius, 4px);
}
</style>

View File

@@ -0,0 +1,485 @@
---
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;
type?: "website" | "article";
}
const { title, description, image, type = "website" } = Astro.props;
const settings = await getSiteSettings();
const siteTitle = settings?.title || "Studio";
const fullTitle = title ? `${title} — ${siteTitle}` : siteTitle;
const siteDescription = settings?.tagline || "Design & Development";
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: type,
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-serif" preload />
<link
rel="alternate"
type="application/rss+xml"
title={`${siteTitle} RSS Feed`}
href="/rss.xml"
/>
<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-title">
{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>
))
}
<a href="/_emdash/admin" class="nav-admin">Admin</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 || "Design & Development"}
</p>
</div>
<div class="footer-right">
<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>
</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 - Elegant/Minimal palette */
--color-bg: #fafafa;
--color-text: #1a1a1a;
--color-muted: #6b7280;
--color-border: #e5e7eb;
--color-surface: #ffffff;
--color-accent: #7c3aed;
--color-accent-muted: #a78bfa;
/* Typography */
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, monospace;
--font-size-sm: 0.875rem;
--font-size-base: 1.0625rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
--font-size-3xl: 2.75rem;
--font-size-4xl: 3.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;
/* Layout */
--max-width: 720px;
--wide-width: 1200px;
--radius: 4px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
}
/* Dark mode via system preference (when no explicit class) */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--color-bg: #0a0a0a;
--color-text: #f5f5f5;
--color-muted: #a1a1aa;
--color-border: #27272a;
--color-surface: #18181b;
--color-accent: #a78bfa;
--color-accent-muted: #7c3aed;
}
}
/* Explicit dark mode */
:root.dark {
--color-bg: #0a0a0a;
--color-text: #f5f5f5;
--color-muted: #a1a1aa;
--color-border: #27272a;
--color-surface: #18181b;
--color-accent: #a78bfa;
--color-accent-muted: #7c3aed;
}
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;
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-serif);
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: var(--font-size-4xl);
}
h2 {
font-size: var(--font-size-2xl);
}
h3 {
font-size: var(--font-size-xl);
}
}
</style>
<style>
.site-header {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-xl) var(--spacing-lg);
}
.nav {
display: flex;
justify-content: space-between;
align-items: center;
}
.site-title {
font-family: var(--font-serif);
font-size: var(--font-size-lg);
font-weight: 600;
text-decoration: none;
letter-spacing: -0.02em;
}
.site-logo-img {
height: 48px;
width: auto;
display: block;
margin: -8px 0;
}
.nav-links {
display: flex;
gap: var(--spacing-xl);
font-size: var(--font-size-sm);
}
.nav-links a {
text-decoration: none;
color: var(--color-muted);
transition: color var(--transition-fast);
}
.nav-links a:hover {
color: var(--color-text);
}
.nav-links a:empty {
display: none;
}
.nav-admin {
opacity: 0.5;
}
main {
min-height: calc(100vh - 200px);
}
.site-footer {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-3xl) var(--spacing-lg);
border-top: 1px solid var(--color-border);
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.footer-logo {
font-family: var(--font-serif);
font-size: var(--font-size-lg);
font-weight: 600;
}
.footer-logo-img {
height: 24px;
width: auto;
}
.footer-tagline {
font-family: var(--font-serif);
font-style: italic;
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-top: var(--spacing-xs);
}
.footer-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--spacing-md);
}
.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);
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-surface);
color: var(--color-text);
border-color: var(--color-accent);
}
.footer-powered {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.footer-powered a {
color: var(--color-text);
text-decoration: none;
}
.footer-powered a:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.nav {
flex-direction: column;
gap: var(--spacing-md);
align-items: center;
}
.nav-links {
flex-wrap: wrap;
justify-content: center;
}
.footer-content {
flex-direction: column;
gap: var(--spacing-xl);
align-items: center;
text-align: center;
}
.footer-right {
align-items: center;
}
h1 {
font-size: var(--font-size-3xl);
}
}
</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,97 @@
---
import Base from "../layouts/Base.astro";
---
<Base title="Page not found">
<div class="not-found">
<span class="not-found-code">404</span>
<h1>Page not found</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<div class="not-found-links">
<a href="/" class="link-primary">Go home</a>
<a href="/work" class="link-secondary">View our work</a>
</div>
</div>
</Base>
<style>
.not-found {
text-align: center;
padding: var(--spacing-4xl) var(--spacing-lg);
max-width: var(--max-width);
margin: 0 auto;
}
.not-found-code {
display: block;
font-family: var(--font-serif);
font-size: 8rem;
font-weight: 500;
line-height: 1;
color: var(--color-border);
margin-bottom: var(--spacing-lg);
}
.not-found h1 {
font-family: var(--font-serif);
font-size: var(--font-size-2xl);
font-weight: 500;
margin-bottom: var(--spacing-sm);
}
.not-found p {
color: var(--color-muted);
margin-bottom: var(--spacing-2xl);
}
.not-found-links {
display: flex;
gap: var(--spacing-md);
justify-content: center;
}
.link-primary {
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-sm);
color: var(--color-bg);
background: var(--color-text);
text-decoration: none;
border-radius: var(--radius);
transition:
background var(--transition-fast),
transform var(--transition-fast);
}
.link-primary:hover {
background: var(--color-accent);
color: white;
transform: translateY(-1px);
}
.link-secondary {
padding: var(--spacing-sm) var(--spacing-lg);
font-size: var(--font-size-sm);
color: var(--color-text);
text-decoration: none;
border: 1px solid var(--color-border);
border-radius: var(--radius);
transition:
border-color var(--transition-fast),
transform var(--transition-fast);
}
.link-secondary:hover {
border-color: var(--color-text);
transform: translateY(-1px);
}
@media (max-width: 480px) {
.not-found-code {
font-size: 5rem;
}
.not-found-links {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,172 @@
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro";
const { entry: page, cacheHint } = await getEmDashEntry("pages", "about");
Astro.cache.set(cacheHint);
---
<Base title="About" description="Learn more about who we are and what we do.">
<div class="about-page">
<header class="about-header">
<h1 class="about-title" {...page?.edit?.title}>
{page?.data?.title || "About"}
</h1>
</header>
<div class="about-content" {...page?.edit?.content}>
{
page?.data?.content ? (
<PortableText value={page.data.content} />
) : (
<p>Content coming soon.</p>
)
}
</div>
<aside class="about-sidebar">
<div class="sidebar-section">
<h3>Services</h3>
<ul>
<li>Brand Identity</li>
<li>Web Design & Development</li>
<li>Print Design</li>
<li>Photography & Art Direction</li>
</ul>
</div>
<div class="sidebar-section">
<h3>Contact</h3>
<p>
<a href="mailto:hello@studio.example">hello@studio.example</a>
</p>
<p>
<a href="/contact">Send us a message →</a>
</p>
</div>
</aside>
</div>
</Base>
<style>
.about-page {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-2xl) var(--spacing-lg) var(--spacing-4xl);
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: auto 1fr;
gap: var(--spacing-4xl);
}
.about-header {
grid-column: 1 / -1;
}
.about-title {
font-family: var(--font-serif);
font-size: var(--font-size-4xl);
font-weight: 500;
line-height: 1.1;
}
.about-content {
font-size: var(--font-size-base);
line-height: 1.7;
}
.about-content :global(p) {
margin-bottom: 1.5em;
}
.about-content :global(h2) {
font-family: var(--font-serif);
font-size: var(--font-size-2xl);
font-weight: 500;
margin-top: 2.5em;
margin-bottom: 0.75em;
}
.about-content :global(h3) {
font-family: var(--font-serif);
font-size: var(--font-size-xl);
font-weight: 500;
margin-top: 2em;
margin-bottom: 0.5em;
}
.about-sidebar {
position: sticky;
top: var(--spacing-xl);
align-self: start;
}
.sidebar-section {
margin-bottom: var(--spacing-2xl);
}
.sidebar-section h3 {
font-family: var(--font-serif);
font-size: var(--font-size-lg);
font-weight: 500;
margin-bottom: var(--spacing-md);
}
.sidebar-section ul {
list-style: none;
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.sidebar-section li {
margin-bottom: var(--spacing-sm);
}
.sidebar-section p {
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-sm);
}
.sidebar-section a {
color: var(--color-text);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-section a:hover {
color: var(--color-accent);
}
@media (max-width: 768px) {
.about-page {
grid-template-columns: 1fr;
gap: var(--spacing-2xl);
}
.about-title {
font-size: var(--font-size-3xl);
}
.about-sidebar {
position: static;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-xl);
padding-top: var(--spacing-xl);
border-top: 1px solid var(--color-border);
}
.sidebar-section {
margin-bottom: 0;
}
}
@media (max-width: 480px) {
.about-sidebar {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,357 @@
---
import Base from "../layouts/Base.astro";
// Handle form submission
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 message = formData.get("message")?.toString() || "";
// Basic validation
if (!name || !email || !message) {
formStatus = "error";
formMessage = "Please fill in all fields.";
} else if (!email.includes("@")) {
formStatus = "error";
formMessage = "Please enter a valid email address.";
} else {
// In a real implementation, you would send this to an email service,
// save to database, or forward to a webhook.
// For this template, we just show a success message.
console.log("Contact form submission:", { name, email, message });
formStatus = "success";
formMessage = "Thanks for reaching out! We'll get back to you soon.";
}
} catch {
formStatus = "error";
formMessage = "Something went wrong. Please try again.";
}
}
---
<Base title="Contact" description="Get in touch with us about your next project.">
<div class="contact-page">
<header class="contact-header">
<h1 class="contact-title">Get in Touch</h1>
<p class="contact-intro">
Have a project in mind? We'd love to hear about it. Fill out the form below
and we'll get back to you within 24 hours.
</p>
</header>
<div class="contact-grid">
<div class="contact-form-wrapper">
{formStatus === "success" ? (
<div class="form-success">
<h2>Message Sent</h2>
<p>{formMessage}</p>
<a href="/contact" class="form-reset">Send another message</a>
</div>
) : (
<form method="POST" class="contact-form">
{formStatus === "error" && (
<div class="form-error">
<p>{formMessage}</p>
</div>
)}
<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@example.com"
autocomplete="email"
/>
</div>
<div class="form-field">
<label for="message">Message</label>
<textarea
id="message"
name="message"
required
rows="6"
placeholder="Tell us about your project..."
></textarea>
</div>
<button type="submit" class="form-submit">
Send Message
</button>
</form>
)}
</div>
<aside class="contact-info">
<div class="info-section">
<h3>Email</h3>
<p>
<a href="mailto:hello@studio.example">hello@studio.example</a>
</p>
</div>
<div class="info-section">
<h3>Location</h3>
<p>San Francisco, CA</p>
</div>
<div class="info-section">
<h3>Social</h3>
<ul class="social-links">
<li><a href="https://twitter.com" target="_blank" rel="noopener noreferrer">Twitter</a></li>
<li><a href="https://instagram.com" target="_blank" rel="noopener noreferrer">Instagram</a></li>
<li><a href="https://dribbble.com" target="_blank" rel="noopener noreferrer">Dribbble</a></li>
<li><a href="https://linkedin.com" target="_blank" rel="noopener noreferrer">LinkedIn</a></li>
</ul>
</div>
</aside>
</div>
</div>
</Base>
<style>
.contact-page {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-2xl) var(--spacing-lg) var(--spacing-4xl);
}
.contact-header {
max-width: var(--max-width);
margin-bottom: var(--spacing-3xl);
}
.contact-title {
font-family: var(--font-serif);
font-size: var(--font-size-4xl);
font-weight: 500;
line-height: 1.1;
margin-bottom: var(--spacing-lg);
}
.contact-intro {
font-size: var(--font-size-lg);
color: var(--color-muted);
line-height: 1.6;
}
.contact-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--spacing-4xl);
}
.contact-form {
display: flex;
flex-direction: column;
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-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
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-accent);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
.form-field input::placeholder,
.form-field textarea::placeholder {
color: var(--color-muted);
}
.form-field textarea {
resize: vertical;
min-height: 150px;
}
.form-submit {
align-self: flex-start;
padding: var(--spacing-md) var(--spacing-xl);
font-family: inherit;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-bg);
background: var(--color-text);
border: none;
border-radius: var(--radius);
cursor: pointer;
transition:
background var(--transition-fast),
transform var(--transition-fast);
}
.form-submit:hover {
background: var(--color-accent);
color: white;
transform: translateY(-1px);
}
.form-submit:active {
transform: translateY(0);
}
.form-error {
padding: var(--spacing-md);
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: var(--radius);
color: #dc2626;
font-size: var(--font-size-sm);
}
@media (prefers-color-scheme: dark) {
.form-error {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #f87171;
}
}
.form-success {
padding: var(--spacing-2xl);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
text-align: center;
}
.form-success h2 {
font-family: var(--font-serif);
font-size: var(--font-size-xl);
font-weight: 500;
margin-bottom: var(--spacing-sm);
}
.form-success p {
color: var(--color-muted);
margin-bottom: var(--spacing-lg);
}
.form-reset {
font-size: var(--font-size-sm);
color: var(--color-accent);
text-decoration: none;
}
.form-reset:hover {
text-decoration: underline;
}
.contact-info {
position: sticky;
top: var(--spacing-xl);
align-self: start;
}
.info-section {
margin-bottom: var(--spacing-2xl);
}
.info-section h3 {
font-family: var(--font-serif);
font-size: var(--font-size-lg);
font-weight: 500;
margin-bottom: var(--spacing-sm);
}
.info-section p,
.info-section a {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.info-section a {
text-decoration: none;
transition: color var(--transition-fast);
}
.info-section a:hover {
color: var(--color-text);
}
.social-links {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.social-links a {
color: var(--color-muted);
}
@media (max-width: 768px) {
.contact-grid {
grid-template-columns: 1fr;
gap: var(--spacing-2xl);
}
.contact-title {
font-size: var(--font-size-3xl);
}
.contact-info {
position: static;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-xl);
padding-top: var(--spacing-xl);
border-top: 1px solid var(--color-border);
}
.info-section {
margin-bottom: 0;
}
}
@media (max-width: 480px) {
.contact-info {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,170 @@
---
import { getEmDashCollection, getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";
import ProjectCard from "../components/ProjectCard.astro";
// Fetch settings + the 4 featured projects in parallel. Limiting and
// sorting in the database avoids loading every project just to slice
// off the first 4 in JS (a full-table read on sites with lots of work).
const [settings, { entries: featuredProjects, cacheHint }] = await Promise.all([
getSiteSettings(),
getEmDashCollection("projects", {
orderBy: { published_at: "desc" },
limit: 4,
}),
]);
Astro.cache.set(cacheHint);
---
<Base>
<section class="hero">
<h1 class="hero-title">{settings?.title || "Studio"}</h1>
<p class="hero-tagline">{settings?.tagline || "Design & Development"}</p>
</section>
{
featuredProjects.length > 0 ? (
<section class="featured">
<header class="section-header">
<h2 class="section-title">Selected Work</h2>
<a href="/work" class="section-link">
View all projects &rarr;
</a>
</header>
<div class="projects-grid">
{featuredProjects.map((project) => (
<ProjectCard
title={project.data.title ?? "Untitled"}
summary={project.data.summary}
featuredImage={project.data.featured_image ?? ""}
href={`/work/${project.id}`}
client={project.data.client}
year={project.data.year}
/>
))}
</div>
</section>
) : (
<section class="empty-state">
<h2>No projects yet</h2>
<p>Add your first project in the admin panel.</p>
<a href="/_emdash/admin/content/projects/new" class="btn">
Add a project
</a>
</section>
)
}
</Base>
<style>
.hero {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-4xl) var(--spacing-lg);
text-align: center;
}
.hero-title {
font-size: var(--font-size-4xl);
font-weight: 500;
margin-bottom: var(--spacing-md);
letter-spacing: -0.02em;
}
.hero-tagline {
font-size: var(--font-size-xl);
color: var(--color-muted);
font-family: var(--font-serif);
font-style: italic;
}
.featured {
max-width: var(--wide-width);
margin: 0 auto;
padding: 0 var(--spacing-lg) var(--spacing-4xl);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--spacing-2xl);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.section-title {
font-size: var(--font-size-lg);
font-weight: 500;
}
.section-link {
font-size: var(--font-size-sm);
color: var(--color-muted);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-text);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-2xl);
}
.empty-state {
text-align: center;
padding: var(--spacing-4xl) var(--spacing-lg);
max-width: 400px;
margin: 0 auto;
}
.empty-state h2 {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-sm);
}
.empty-state p {
color: var(--color-muted);
margin-bottom: var(--spacing-lg);
}
.btn {
display: inline-block;
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--color-text);
color: var(--color-bg);
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
transition: opacity var(--transition-fast);
}
.btn:hover {
opacity: 0.85;
}
@media (max-width: 768px) {
.hero {
padding: var(--spacing-3xl) var(--spacing-lg);
}
.hero-title {
font-size: var(--font-size-3xl);
}
.projects-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
gap: var(--spacing-sm);
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,70 @@
import type { APIRoute } from "astro";
import { getEmDashCollection, getSiteSettings } from "emdash";
export const GET: APIRoute = async ({ site, url }) => {
const siteUrl = site?.toString() || url.origin;
const settings = await getSiteSettings();
const siteTitle = settings?.title || "Studio";
const siteDescription = settings?.tagline || "Design & Development";
const { entries: projects } = await getEmDashCollection("projects", {
orderBy: { published_at: "desc" },
limit: 20,
});
const items = projects
.map((project) => {
if (!project.data.publishedAt) return null;
const pubDate = project.data.publishedAt.toUTCString();
const projectUrl = `${siteUrl}/work/${project.id}`;
const title = escapeXml(project.data.title || "Untitled");
const description = escapeXml(project.data.summary || "");
return ` <item>
<title>${title}</title>
<link>${projectUrl}</link>
<guid isPermaLink="true">${projectUrl}</guid>
<pubDate>${pubDate}</pubDate>
<description>${description}</description>
</item>`;
})
.filter(Boolean)
.join("\n");
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteTitle)}</title>
<description>${escapeXml(siteDescription)}</description>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
};
const XML_ESCAPE_PATTERNS = [
[/&/g, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
[/'/g, "&apos;"],
] as const;
function escapeXml(str: string): string {
let result = str;
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}

View File

@@ -0,0 +1,304 @@
---
import { getEmDashEntry, getEmDashCollection, decodeSlug } from "emdash";
import { Image, PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
import ProjectCard from "../../components/ProjectCard.astro";
const slug = decodeSlug(Astro.params.slug);
if (!slug) {
return Astro.redirect("/404");
}
const { entry: project, cacheHint } = await getEmDashEntry("projects", slug);
if (!project) {
return Astro.redirect("/404");
}
Astro.cache.set(cacheHint);
// Related projects: order by date in the DB and request a small lookahead
// so we can drop the current entry without re-querying. Avoids loading
// every project just to slice off two.
const { entries: relatedProjects } = await getEmDashCollection("projects", {
orderBy: { published_at: "desc" },
limit: 3,
});
const otherProjects = relatedProjects.filter((p) => p.id !== project.id).slice(0, 2);
// Parse gallery if present
const gallery = project.data.gallery as
| Array<{ url: string; alt?: string }>
| undefined;
// Get image src for OG image
function getImageSrc(img: unknown): string | undefined {
if (!img || typeof img !== "object") return undefined;
const image = img as Record<string, unknown>;
return typeof image.src === "string" ? image.src : undefined;
}
---
<Base
title={project.data.title}
description={project.data.summary}
image={getImageSrc(project.data.featured_image)}
>
<article class="project">
<header class="project-header">
<div class="project-meta">
{
project.data.client && (
<span class="project-client" {...project.edit.client}>
{project.data.client}
</span>
)
}
{
project.data.client && project.data.year && (
<span class="meta-separator">·</span>
)
}
{
project.data.year && (
<span class="project-year" {...project.edit.year}>
{project.data.year}
</span>
)
}
</div>
<h1 class="project-title" {...project.edit.title}>
{project.data.title}
</h1>
{
project.data.summary && (
<p class="project-summary" {...project.edit.summary}>
{project.data.summary}
</p>
)
}
{
project.data.url && (
<a
href={project.data.url ?? "#"}
class="project-link"
target="_blank"
rel="noopener noreferrer"
>
View Live Project →
</a>
)
}
</header>
{
project.data.featured_image && (
<div class="featured-image" {...project.edit.featured_image}>
<Image image={project.data.featured_image} />
</div>
)
}
<div class="project-content">
<PortableText value={project.data.content} />
</div>
{
gallery && gallery.length > 0 && (
<div class="project-gallery">
{gallery.map((img) => (
<div class="gallery-item">
<img src={img.url} alt={img.alt || ""} loading="lazy" />
</div>
))}
</div>
)
}
</article>
{
otherProjects.length > 0 && (
<section class="more-projects">
<div class="more-inner">
<h2 class="more-title">More Work</h2>
<div class="more-grid">
{otherProjects.map((p) => (
<ProjectCard
title={p.data.title ?? "Untitled"}
summary={p.data.summary}
featuredImage={p.data.featured_image ?? ""}
href={`/work/${p.id}`}
client={p.data.client}
year={p.data.year}
/>
))}
</div>
</div>
</section>
)
}
</Base>
<style>
.project {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--spacing-lg) var(--spacing-4xl);
}
.project-header {
margin-bottom: var(--spacing-2xl);
}
.project-meta {
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.meta-separator {
margin: 0 var(--spacing-sm);
}
.project-title {
font-family: var(--font-serif);
font-size: var(--font-size-4xl);
font-weight: 500;
line-height: 1.1;
margin-bottom: var(--spacing-lg);
}
.project-summary {
font-size: var(--font-size-lg);
color: var(--color-muted);
line-height: 1.6;
margin-bottom: var(--spacing-lg);
}
.project-link {
display: inline-block;
font-size: var(--font-size-sm);
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.project-link:hover {
color: var(--color-text);
}
.featured-image {
margin-bottom: var(--spacing-3xl);
}
.featured-image img {
width: 100%;
height: auto;
border-radius: var(--radius);
}
.project-content {
font-size: var(--font-size-base);
line-height: 1.7;
}
.project-content :global(p) {
margin-bottom: 1.5em;
}
.project-content :global(h2) {
font-family: var(--font-serif);
font-size: var(--font-size-2xl);
font-weight: 500;
margin-top: 2.5em;
margin-bottom: 0.75em;
}
.project-content :global(h3) {
font-family: var(--font-serif);
font-size: var(--font-size-xl);
font-weight: 500;
margin-top: 2em;
margin-bottom: 0.5em;
}
.project-content :global(blockquote) {
margin: 2em 0;
padding-left: var(--spacing-xl);
border-left: 2px solid var(--color-accent);
color: var(--color-muted);
font-family: var(--font-serif);
font-style: italic;
font-size: var(--font-size-lg);
}
.project-content :global(ul),
.project-content :global(ol) {
margin-bottom: 1.5em;
padding-left: 1.25em;
}
.project-content :global(li) {
margin-bottom: 0.5em;
}
.project-content :global(img) {
margin: 2em 0;
border-radius: var(--radius);
}
.project-gallery {
margin-top: var(--spacing-3xl);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-lg);
}
.gallery-item img {
width: 100%;
height: auto;
border-radius: var(--radius);
}
.more-projects {
background: var(--color-surface);
padding: var(--spacing-4xl) 0;
margin-top: var(--spacing-2xl);
}
.more-inner {
max-width: var(--wide-width);
margin: 0 auto;
padding: 0 var(--spacing-lg);
}
.more-title {
font-family: var(--font-serif);
font-size: var(--font-size-2xl);
font-weight: 500;
margin-bottom: var(--spacing-2xl);
}
.more-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-2xl);
}
@media (max-width: 768px) {
.project-title {
font-size: var(--font-size-3xl);
}
.project-gallery {
grid-template-columns: 1fr;
}
.more-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,217 @@
---
export const prerender = false;
import {
getEmDashCollection,
getTaxonomyTerms,
getTermsForEntries,
} from "emdash";
import Base from "../../layouts/Base.astro";
import ProjectCard from "../../components/ProjectCard.astro";
// Get filter from query param. When set, push the filter down to the
// database via `where` instead of loading every project and filtering
// in JS — that pattern scales O(projects × terms-per-project) and
// blows up the moment you have more than a couple of dozen entries.
const activeTag = Astro.url.searchParams.get("tag");
const [{ entries: sortedProjects, cacheHint }, tags] = await Promise.all([
getEmDashCollection("projects", {
orderBy: { published_at: "desc" },
...(activeTag ? { where: { tag: activeTag } } : {}),
}),
getTaxonomyTerms("tag"),
]);
Astro.cache.set(cacheHint);
// Single batched JOIN for the tag pills shown on each card, instead of
// one getEntryTerms() round-trip per card.
const tagsByEntry = await getTermsForEntries(
"projects",
sortedProjects.map((p) => p.data.id),
"tag",
);
const projectsWithTags = sortedProjects.map((project) => ({
project,
projectTags: tagsByEntry.get(project.data.id) ?? [],
}));
---
<Base
title={activeTag
? `${tags.find((t) => t.slug === activeTag)?.label || activeTag} — Work`
: "Work"}
>
<section class="work-header">
<h1>Work</h1>
<p class="work-intro">
A selection of projects spanning branding, web design, print, and
photography.
</p>
{
tags.length > 0 && (
<nav class="tag-filters" aria-label="Filter by tag">
{tags.map((tag) => (
<a
href={`/work?tag=${tag.slug}`}
class:list={["tag-filter", { active: activeTag === tag.slug }]}
>
{tag.label}
</a>
))}
{activeTag && (
<a href="/work" class="tag-clear" aria-label="Clear filter">
Clear
</a>
)}
</nav>
)
}
</section>
{
projectsWithTags.length > 0 ? (
<section class="projects">
<div class="projects-grid">
{projectsWithTags.map(({ project, projectTags }) => (
<ProjectCard
title={project.data.title ?? "Untitled"}
summary={project.data.summary}
featuredImage={project.data.featured_image ?? ""}
href={`/work/${project.id}`}
client={project.data.client}
year={project.data.year}
tags={projectTags.map((t) => t.label)}
/>
))}
</div>
</section>
) : (
<section class="empty-state">
{activeTag ? (
<>
<p>No projects found with this tag.</p>
<a href="/work" class="btn">
View all projects
</a>
</>
) : (
<>
<p>No projects yet. Add your first project in the admin panel.</p>
<a href="/_emdash/admin/content/projects/new" class="btn">
Add a project
</a>
</>
)}
</section>
)
}
</Base>
<style>
.work-header {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-3xl) var(--spacing-lg) var(--spacing-2xl);
}
.work-header h1 {
font-size: var(--font-size-3xl);
margin-bottom: var(--spacing-md);
}
.work-intro {
font-size: var(--font-size-lg);
color: var(--color-muted);
max-width: 600px;
}
.tag-filters {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
margin-top: var(--spacing-xl);
}
.tag-filter {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--color-muted);
text-decoration: none;
border: 1px solid var(--color-border);
border-radius: var(--radius);
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
.tag-filter:hover {
color: var(--color-text);
border-color: var(--color-text);
}
.tag-filter.active {
color: var(--color-bg);
background: var(--color-text);
border-color: var(--color-text);
}
.tag-clear {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
font-size: var(--font-size-sm);
color: var(--color-muted);
text-decoration: none;
transition: color var(--transition-fast);
}
.tag-clear:hover {
color: var(--color-text);
}
.tag-clear::before {
content: "×";
font-size: 1.1em;
}
.projects {
max-width: var(--wide-width);
margin: 0 auto;
padding: 0 var(--spacing-lg) var(--spacing-4xl);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-2xl);
}
.empty-state {
text-align: center;
padding: var(--spacing-4xl) var(--spacing-lg);
color: var(--color-muted);
}
.btn {
display: inline-block;
margin-top: var(--spacing-lg);
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--color-text);
color: var(--color-bg);
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
@media (max-width: 768px) {
.projects-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,62 @@
/*
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: #fafafa;
--color-text: #1a1a1a;
--color-muted: #6b7280;
--color-border: #e5e7eb;
--color-surface: #ffffff;
--color-accent: #7c3aed;
--color-accent-muted: #a78bfa;
*/
/* --- Typography ---
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, monospace;
--font-size-sm: 0.875rem;
--font-size-base: 1.0625rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
--font-size-3xl: 2.75rem;
--font-size-4xl: 3.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;
*/
/* --- Layout ---
--max-width: 720px;
--wide-width: 1200px;
--radius: 4px;
*/
/* --- Transitions ---
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
*/
}