Add portfolio template to combined marketing+portfolio template

- Added portfolio pages: portfolio-index, portfolio-about, portfolio-contact
- Added work/[slug].astro for project detail pages
- Added PortfolioBase.astro layout with Playfair Display font
- Added ProjectCard.astro component
- Added projects collection with taxonomies (category, tag)
- Updated theme.css with --font-serif variable
- Added portfolio seed data with 4 projects
- Updated menus to include Work link
This commit is contained in:
Kunthawat Greethong
2026-05-01 13:22:24 +07:00
parent 9147821a16
commit 978bf42e5a
19 changed files with 8804 additions and 7 deletions

154
.astro/content.d.ts vendored Normal file
View File

@@ -0,0 +1,154 @@
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof DataEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
collection: C;
id: string;
};
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof DataEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter?: LiveLoaderCollectionFilterType<C>,
): Promise<
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
collection: C,
filter: string | LiveLoaderEntryFilterType<C>,
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof DataEntryMap>(
entry: DataEntryMap[C][string],
): Promise<RenderResult>;
export function reference<
C extends
| keyof DataEntryMap
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
| (string & {}),
>(
collection: C,
): import('astro/zod').ZodPipe<
import('astro/zod').ZodString,
import('astro/zod').ZodTransform<
C extends keyof DataEntryMap
? {
collection: C;
id: string;
}
: never,
string
>
>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
type InferLoaderSchema<
C extends keyof DataEntryMap,
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
> = L extends { schema: import('astro/zod').ZodSchema }
? import('astro/zod').infer<L['schema']>
: any;
type DataEntryMap = {
};
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
infer TData,
infer TEntryFilter,
infer TCollectionFilter,
infer TError
>
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
: { data: never; entryFilter: never; collectionFilter: never; error: never };
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
LiveContentConfig['collections'][C]['schema'] extends undefined
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
: import('astro/zod').infer<
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
>;
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
LiveContentConfig['collections'][C]['loader']
>;
export type ContentConfig = never;
export type LiveContentConfig = typeof import("../src/live.config.js");
}

4
.astro/fonts.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module 'astro:assets' {
/** @internal */
export type CssVariable = (["--font-emdash"])[number];
}

3
.astro/types.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />
/// <reference path="fonts.d.ts" />

191
.omc/project-memory.json Normal file
View File

@@ -0,0 +1,191 @@
{
"version": "1.0.0",
"lastScanned": 1777614137874,
"projectRoot": "/Users/kunthawatgreethong/Gitea/claude-skill/skills/website-creator/templates/astro-emdash-marketing",
"techStack": {
"languages": [
{
"name": "JavaScript/TypeScript",
"version": null,
"confidence": "high",
"markers": [
"package.json"
]
},
{
"name": "TypeScript",
"version": null,
"confidence": "high",
"markers": [
"tsconfig.json"
]
}
],
"frameworks": [
{
"name": "astro",
"version": "6.0.1",
"category": "fullstack"
},
{
"name": "react",
"version": "19.2.4",
"category": "frontend"
},
{
"name": "react-dom",
"version": "19.2.4",
"category": "frontend"
}
],
"packageManager": "npm",
"runtime": null
},
"build": {
"buildCommand": "pnpm build 2>&1",
"testCommand": null,
"lintCommand": null,
"devCommand": "npm run dev",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node ./dist/server/entry.mjs",
"bootstrap": "emdash init && emdash seed",
"seed": "emdash seed",
"typecheck": "astro check"
}
},
"conventions": {
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": "type-based"
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [
"public",
"src"
],
"gitBranches": null
},
"customNotes": [],
"directoryMap": {
"public": {
"path": "public",
"purpose": "Public files",
"fileCount": 0,
"lastAccessed": 1777614137858,
"keyFiles": []
},
"seed": {
"path": "seed",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1777614137858,
"keyFiles": [
"seed.json"
]
},
"src": {
"path": "src",
"purpose": "Source code",
"fileCount": 1,
"lastAccessed": 1777614137861,
"keyFiles": [
"live.config.ts"
]
},
"src/components": {
"path": "src/components",
"purpose": "UI components",
"fileCount": 1,
"lastAccessed": 1777614137862,
"keyFiles": [
"MarketingBlocks.astro"
]
},
"src/pages": {
"path": "src/pages",
"purpose": "Page components",
"fileCount": 3,
"lastAccessed": 1777614137862,
"keyFiles": [
"contact.astro",
"index.astro",
"pricing.astro"
]
}
},
"hotPaths": [
{
"path": "seed/seed.json",
"accessCount": 5,
"lastAccessed": 1777616481729,
"type": "file"
},
{
"path": "src/styles/theme.css",
"accessCount": 2,
"lastAccessed": 1777616344900,
"type": "file"
},
{
"path": "src/pages/index.astro",
"accessCount": 1,
"lastAccessed": 1777614147925,
"type": "file"
},
{
"path": "src/layouts/PortfolioBase.astro",
"accessCount": 1,
"lastAccessed": 1777616188780,
"type": "file"
},
{
"path": "src/components/ProjectCard.astro",
"accessCount": 1,
"lastAccessed": 1777616192120,
"type": "file"
},
{
"path": "src/pages/portfolio-index.astro",
"accessCount": 1,
"lastAccessed": 1777616195418,
"type": "file"
},
{
"path": "src/pages/work/[slug].astro",
"accessCount": 1,
"lastAccessed": 1777616198707,
"type": "file"
},
{
"path": "src/pages/about.astro",
"accessCount": 1,
"lastAccessed": 1777616201998,
"type": "file"
},
{
"path": "src/pages/contact.astro",
"accessCount": 1,
"lastAccessed": 1777616210412,
"type": "file"
},
{
"path": "src/pages/portfolio-contact.astro",
"accessCount": 1,
"lastAccessed": 1777616255490,
"type": "file"
},
{
"path": "src/pages/portfolio-about.astro",
"accessCount": 1,
"lastAccessed": 1777616259549,
"type": "file"
}
],
"userDirectives": []
}

View File

@@ -3,3 +3,6 @@
{"t":0,"agent":"a0f2988","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":35179}
{"t":0,"agent":"a338ed5","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a03ceb2","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"a5d5029","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"ada0ac0","agent_type":"unknown","event":"agent_stop","success":true}
{"t":0,"agent":"ae2f4e3","agent_type":"unknown","event":"agent_stop","success":true}

View File

@@ -0,0 +1,16 @@
{
"created_at": "2026-05-01T05:40:41.335Z",
"trigger": "auto",
"active_modes": {},
"todo_summary": {
"pending": 0,
"in_progress": 0,
"completed": 0
},
"wisdom_exported": false,
"background_jobs": {
"active": [],
"recent": [],
"stats": null
}
}

View File

@@ -0,0 +1,16 @@
{
"created_at": "2026-05-01T06:12:29.590Z",
"trigger": "auto",
"active_modes": {},
"todo_summary": {
"pending": 0,
"in_progress": 0,
"completed": 0
},
"wisdom_exported": false,
"background_jobs": {
"active": [],
"recent": [],
"stats": null
}
}

View File

@@ -1,5 +1,5 @@
{
"updatedAt": "2026-05-01T04:04:54.735Z",
"updatedAt": "2026-05-01T06:13:19.823Z",
"missions": [
{
"id": "session:33698839-2ad1-4412-9735-43676f5e6beb:none",
@@ -7,7 +7,7 @@
"name": "none",
"objective": "Session mission",
"createdAt": "2026-04-30T23:41:28.830Z",
"updatedAt": "2026-05-01T04:04:54.735Z",
"updatedAt": "2026-05-01T06:13:19.823Z",
"status": "done",
"workerCount": 1,
"taskCounts": {
@@ -27,7 +27,7 @@
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-05-01T04:04:54.735Z"
"updatedAt": "2026-05-01T06:13:19.823Z"
}
],
"timeline": [
@@ -62,6 +62,30 @@
"agent": "Explore:a0f2988",
"detail": "completed",
"sourceKey": "session-stop:a03ceb29df0d9439c"
},
{
"id": "session-stop:a5d502987c545569c:2026-05-01T05:42:16.073Z",
"at": "2026-05-01T05:42:16.073Z",
"kind": "completion",
"agent": "Explore:a0f2988",
"detail": "completed",
"sourceKey": "session-stop:a5d502987c545569c"
},
{
"id": "session-stop:ada0ac096cb8f31aa:2026-05-01T05:42:34.397Z",
"at": "2026-05-01T05:42:34.397Z",
"kind": "completion",
"agent": "Explore:a0f2988",
"detail": "completed",
"sourceKey": "session-stop:ada0ac096cb8f31aa"
},
{
"id": "session-stop:ae2f4e3ced71aa12c:2026-05-01T06:13:19.823Z",
"at": "2026-05-01T06:13:19.823Z",
"kind": "completion",
"agent": "Explore:a0f2988",
"detail": "completed",
"sourceKey": "session-stop:ae2f4e3ced71aa12c"
}
]
}

View File

@@ -13,5 +13,5 @@
"total_spawned": 1,
"total_completed": 1,
"total_failed": 0,
"last_updated": "2026-05-01T04:04:54.837Z"
"last_updated": "2026-05-01T06:13:19.923Z"
}

7201
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "Marketing Starter",
"description": "A conversion-focused marketing site with landing pages",
"name": "Marketing & Portfolio Starter",
"description": "A combined marketing and portfolio site with landing pages and project showcases",
"author": "EmDash"
},
"settings": {
@@ -29,6 +29,90 @@
"type": "portableText"
}
]
},
{
"slug": "projects",
"label": "Projects",
"labelSingular": "Project",
"supports": ["drafts", "revisions", "search", "seo"],
"fields": [
{
"slug": "title",
"label": "Title",
"type": "string",
"required": true,
"searchable": true
},
{
"slug": "featured_image",
"label": "Featured Image",
"type": "image",
"required": true
},
{
"slug": "client",
"label": "Client",
"type": "string"
},
{
"slug": "year",
"label": "Year",
"type": "string"
},
{
"slug": "summary",
"label": "Summary",
"type": "text",
"searchable": true
},
{
"slug": "body",
"label": "Body",
"type": "portableText",
"searchable": true
},
{
"slug": "gallery",
"label": "Gallery",
"type": "json"
},
{
"slug": "url",
"label": "Project URL",
"type": "string"
}
]
}
],
"taxonomies": [
{
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": false,
"collections": ["projects"],
"terms": [
{ "slug": "branding", "label": "Branding" },
{ "slug": "web", "label": "Web Design" },
{ "slug": "print", "label": "Print" },
{ "slug": "photography", "label": "Photography" }
]
},
{
"name": "tag",
"label": "Tags",
"labelSingular": "Tag",
"hierarchical": false,
"collections": ["projects"],
"terms": [
{ "slug": "identity", "label": "Identity" },
{ "slug": "ui-ux", "label": "UI/UX" },
{ "slug": "development", "label": "Development" },
{ "slug": "art-direction", "label": "Art Direction" },
{ "slug": "packaging", "label": "Packaging" },
{ "slug": "ecommerce", "label": "E-commerce" },
{ "slug": "editorial", "label": "Editorial" }
]
}
],
"menus": [
@@ -46,6 +130,11 @@
"label": "Pricing",
"url": "/pricing"
},
{
"type": "custom",
"label": "Work",
"url": "/work"
},
{
"type": "custom",
"label": "Contact",
@@ -277,6 +366,271 @@
}
]
}
},
{
"id": "about",
"slug": "about",
"status": "published",
"data": {
"title": "About",
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "We are a creative studio focused on creating meaningful work for thoughtful clients. Founded in 2020, we've had the privilege of working with brands across technology, culture, and the arts."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Our approach is simple: listen carefully, think deeply, and craft with intention. We believe that good design is invisible — it gets out of the way and lets the work speak for itself."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "What we do"
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "We specialize in brand identity, web design, and art direction. Whether you need a complete visual identity or a single website, we bring the same level of care and attention to every project."
}
]
}
]
}
}
],
"projects": [
{
"id": "meridian-brand",
"slug": "meridian-brand",
"status": "published",
"data": {
"title": "Meridian",
"client": "Meridian Collective",
"year": "2024",
"summary": "A complete brand identity for a creative collective bringing together artists and technologists.",
"featured_image": "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200&h=900&fit=crop",
"body": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Meridian Collective needed a visual identity that could bridge the gap between traditional art and emerging technology. The challenge was to create something that felt timeless yet forward-looking."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "The approach"
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "We developed a wordmark that combines classical typography with subtle geometric forms. The color palette draws from both digital interfaces and natural materials, creating a bridge between worlds."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "The result"
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The new identity has been applied across print and digital touchpoints, from business cards to a fully responsive website. Meridian has received positive feedback from both artists and technologists alike."
}
]
}
]
},
"taxonomies": {
"category": ["branding"],
"tag": ["identity", "art-direction"]
}
},
{
"id": "volta-web",
"slug": "volta-web",
"status": "published",
"data": {
"title": "Volta",
"client": "Volta Energy",
"year": "2024",
"summary": "Website design for a renewable energy startup making clean power accessible to everyone.",
"featured_image": "https://images.unsplash.com/photo-1497366216548-37526070297c?w=1200&h=900&fit=crop",
"url": "https://volta.example.com",
"body": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Volta is on a mission to make renewable energy accessible to everyone. They needed a website that could explain complex technology simply while inspiring trust in potential customers."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "Design principles"
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "We focused on clarity and warmth. The design uses generous whitespace and a vibrant green accent that evokes growth and sustainability without feeling clichéd."
}
]
}
]
},
"taxonomies": {
"category": ["web"],
"tag": ["ui-ux", "development"]
}
},
{
"id": "archive-print",
"slug": "archive-print",
"status": "published",
"data": {
"title": "The Archive",
"client": "City Museum",
"year": "2023",
"summary": "Exhibition catalog and print materials for a retrospective on modernist architecture.",
"featured_image": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=1200&h=900&fit=crop",
"body": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The Archive was a major exhibition at the City Museum, showcasing 50 years of modernist architecture through photographs, drawings, and models. We designed the complete print identity."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "The catalog"
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The 240-page catalog uses a strict grid system inspired by the work being exhibited. Full-bleed photography alternates with dense text spreads, creating a rhythm that echoes the exhibition layout."
}
]
}
]
},
"taxonomies": {
"category": ["print"],
"tag": ["editorial", "art-direction"]
}
},
{
"id": "coastal-photo",
"slug": "coastal-photo",
"status": "published",
"data": {
"title": "Coastal",
"client": "Personal Project",
"year": "2023",
"summary": "A photographic study of the Pacific coastline, exploring the intersection of land, sea, and sky.",
"featured_image": "https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=1200&h=900&fit=crop",
"body": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Coastal began as a weekend project and grew into a year-long exploration of the Pacific shoreline. Shot entirely on medium format film, the series captures the quiet drama of the coast."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "Process"
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Each image was made at dawn or dusk, when the light is softest and the beaches are empty. The slow process of shooting film encourages patience and intentionality — qualities that show in the final work."
}
]
}
]
},
"taxonomies": {
"category": ["photography"],
"tag": ["art-direction", "editorial"]
}
}
]
}

View File

@@ -0,0 +1,113 @@
---
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;
const allTags = [...(categories || []), ...(tags || [])];
---
<div class="project-card">
<a href={href}>
<Image src={featuredImage} alt={title} />
</a>
<div class="content">
<a href={href}><h3 class="title">{title}</h3></a>
{(client || year) && (
<p class="meta">
{client && <span class="client">{client}</span>}
{client && year && <span class="separator"> · </span>}
{year && <span class="year">{year}</span>}
</p>
)}
{summary && <p class="summary">{summary}</p>}
{allTags.length > 0 && (
<ul class="tags">
{allTags.map((tag) => (
<li>{tag}</li>
))}
</ul>
)}
<a href={href} class="view-project">View Project</a>
</div>
</div>
<style>
.project-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all var(--transition-base);
}
.project-card:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
}
.project-card img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.content {
padding: var(--spacing-lg);
}
.title {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-sm);
}
.meta {
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-md);
}
.summary {
color: var(--color-muted);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
list-style: none;
padding: 0;
margin: 0 0 var(--spacing-md);
}
.tags li {
background: var(--color-surface);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
color: var(--color-muted);
}
.view-project {
font-weight: 600;
color: var(--color-primary);
text-decoration: none;
}
.view-project:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,160 @@
---
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 siteFavicon = (settings?.favicon as any)?.url ?? 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>
<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=Playfair+Display:wght@400;500;600;700&display=swap" rel="stylesheet" />
{siteFavicon && <link rel="icon" href={siteFavicon} />}
{description && <meta name="description" content={description} />}
<EmDashHead page={pageCtx} />
</head>
<body>
<header class="site-header">
<nav class="container nav">
<a href="/" class="logo">
{siteLogo ? (
<img src={siteLogo.url} alt={siteLogo.alt || siteTitle} width="32" height="32" />
) : (
<span>{siteTitle}</span>
)}
</a>
<ul class="nav-links">
{menu?.items.map((item: any) => (
<li>
<a href={item.url}>{item.label}</a>
</li>
))}
</ul>
<a href="/contact" class="btn btn-primary">Get in Touch</a>
</nav>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="container">
<p>&copy; {new Date().getFullYear()} {siteTitle}. All rights reserved.</p>
</div>
</footer>
</body>
</html>
<style>
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) 0;
}
.logo {
font-weight: 700;
font-size: var(--font-size-xl);
text-decoration: none;
color: var(--color-text);
font-family: var(--font-serif);
}
.nav-links {
display: flex;
gap: var(--spacing-xl);
list-style: none;
margin: 0;
padding: 0;
}
.nav-links a {
text-decoration: none;
color: var(--color-text);
font-weight: 500;
transition: color var(--transition-fast);
}
.nav-links a:hover {
color: var(--color-primary);
}
.site-footer {
border-top: 1px solid var(--color-border);
padding: var(--spacing-2xl) 0;
text-align: center;
color: var(--color-muted);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius);
font-weight: 600;
text-decoration: none;
transition: all var(--transition-fast);
}
.btn-primary {
background: var(--color-primary);
color: white;
}
.btn-primary:hover {
background: var(--color-primary-dark);
}
@media (max-width: 768px) {
.nav-links {
display: none;
}
}
</style>

65
src/pages/about.astro Normal file
View File

@@ -0,0 +1,65 @@
---
import { getEmDashEntry } from "emdash";
import PortfolioBase from "../layouts/PortfolioBase.astro";
const { entry: page, cacheHint } = await getEmDashEntry("pages", "about");
Astro.cache.set(cacheHint);
---
<PortfolioBase title="About">
<main class="container">
<article>
<h1>About Us</h1>
{page?.data?.content ? (
<div class="content">
{page.data.content.map((block: any) => {
if (block._type === "block") {
return <p>{block.children?.map((c: any) => c.text).join("")}</p>;
}
return null;
})}
</div>
) : (
<div class="content">
<p>We are a creative studio focused on design and development.</p>
<p>Add your about page content in the admin panel.</p>
<a href="/_emdash/admin">Open Admin</a>
</div>
)}
</article>
</main>
</PortfolioBase>
<style>
main.container {
padding: var(--spacing-5xl) var(--spacing-lg);
max-width: 800px;
margin: 0 auto;
}
article h1 {
font-size: var(--font-size-4xl);
margin-bottom: var(--spacing-2xl);
font-family: var(--font-serif);
}
.content {
font-size: var(--font-size-lg);
line-height: 1.7;
}
.content p {
margin-bottom: var(--spacing-lg);
}
.content a {
display: inline-block;
margin-top: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-xl);
background: var(--color-primary);
color: white;
text-decoration: none;
border-radius: var(--radius);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,65 @@
---
import { getEmDashEntry } from "emdash";
import PortfolioBase from "../layouts/PortfolioBase.astro";
const { entry: page, cacheHint } = await getEmDashEntry("pages", "about");
Astro.cache.set(cacheHint);
---
<PortfolioBase title="About">
<main class="container">
<article>
<h1>About Us</h1>
{page?.data?.content ? (
<div class="content">
{page.data.content.map((block: any) => {
if (block._type === "block") {
return <p>{block.children?.map((c: any) => c.text).join("")}</p>;
}
return null;
})}
</div>
) : (
<div class="content">
<p>We are a creative studio focused on design and development.</p>
<p>Add your about page content in the admin panel.</p>
<a href="/_emdash/admin">Open Admin</a>
</div>
)}
</article>
</main>
</PortfolioBase>
<style>
main.container {
padding: var(--spacing-5xl) var(--spacing-lg);
max-width: 800px;
margin: 0 auto;
}
article h1 {
font-size: var(--font-size-4xl);
margin-bottom: var(--spacing-2xl);
font-family: var(--font-serif);
}
.content {
font-size: var(--font-size-lg);
line-height: 1.7;
}
.content p {
margin-bottom: var(--spacing-lg);
}
.content a {
display: inline-block;
margin-top: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-xl);
background: var(--color-primary);
color: white;
text-decoration: none;
border-radius: var(--radius);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,160 @@
---
import PortfolioBase from "../layouts/PortfolioBase.astro";
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() || "";
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 {
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.";
}
}
---
<PortfolioBase title="Contact">
<main class="container">
<h1>Get in Touch</h1>
{formStatus === "success" ? (
<div class="success-message">
<h2>Message Sent</h2>
<p>{formMessage}</p>
<a href="/contact">Send another message</a>
</div>
) : (
<form method="POST" class="contact-form">
{formStatus === "error" && (
<p class="error">{formMessage}</p>
)}
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" placeholder="Your name" required />
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" placeholder="your@email.com" required />
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" placeholder="Tell us about your project" required></textarea>
</div>
<button type="submit">Send Message</button>
</form>
)}
</main>
</PortfolioBase>
<style>
main.container {
padding: var(--spacing-5xl) var(--spacing-lg);
max-width: 600px;
margin: 0 auto;
}
h1 {
font-size: var(--font-size-4xl);
margin-bottom: var(--spacing-2xl);
font-family: var(--font-serif);
text-align: center;
}
.contact-form {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
label {
font-weight: 600;
font-size: var(--font-size-sm);
}
input,
textarea {
padding: var(--spacing-md);
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: var(--font-size-base);
background: var(--color-bg);
color: var(--color-text);
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--color-primary);
}
textarea {
min-height: 150px;
resize: vertical;
}
button {
padding: var(--spacing-md) var(--spacing-xl);
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast);
}
button:hover {
background: var(--color-primary-dark);
}
.error {
color: var(--color-error);
padding: var(--spacing-md);
background: rgba(239, 68, 68, 0.1);
border-radius: var(--radius);
}
.success-message {
text-align: center;
padding: var(--spacing-4xl) 0;
}
.success-message h2 {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-md);
font-family: var(--font-serif);
}
.success-message a {
display: inline-block;
margin-top: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-xl);
background: var(--color-primary);
color: white;
text-decoration: none;
border-radius: var(--radius);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,105 @@
---
import { getEmDashCollection, getSiteSettings } from "emdash";
import PortfolioBase from "../layouts/PortfolioBase.astro";
import ProjectCard from "../components/ProjectCard.astro";
const [settings, { entries: featuredProjects, cacheHint }] = await Promise.all([
getSiteSettings(),
getEmDashCollection("projects", { orderBy: { published_at: "desc" }, limit: 4 }),
]);
Astro.cache.set(cacheHint);
---
<PortfolioBase>
<section class="hero">
<div class="container">
<h1>{settings?.title || "Studio"}</h1>
<p class="tagline">{settings?.tagline || "Design & Development"}</p>
</div>
</section>
<section class="projects-section">
<div class="container">
{featuredProjects.length > 0 ? (
<ul class="projects-grid">
{featuredProjects.map((project) => (
<li>
<ProjectCard
title={project.data.title}
summary={project.data.summary}
featuredImage={project.data.featured_image}
href={`/work/${project.id}`}
client={project.data.client}
year={project.data.year}
categories={project.data.categories}
tags={project.data.tags}
/>
</li>
))}
</ul>
) : (
<div class="empty-state">
<p>No projects yet</p>
<p>Add your first project in the admin panel.</p>
<a href="/_emdash/admin">Open Admin</a>
</div>
)}
</div>
</section>
</PortfolioBase>
<style>
.hero {
padding: var(--spacing-5xl) 0;
text-align: center;
background: linear-gradient(180deg, var(--color-surface) 0%, var(--color-bg) 100%);
}
.hero h1 {
font-size: var(--font-size-5xl);
font-weight: 800;
margin-bottom: var(--spacing-md);
font-family: var(--font-serif);
letter-spacing: -0.02em;
}
.tagline {
font-size: var(--font-size-xl);
color: var(--color-muted);
}
.projects-section {
padding: var(--spacing-5xl) 0;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-xl);
list-style: none;
padding: 0;
margin: 0;
}
.empty-state {
text-align: center;
padding: var(--spacing-5xl) 0;
}
.empty-state a {
display: inline-block;
margin-top: var(--spacing-lg);
padding: var(--spacing-md) var(--spacing-xl);
background: var(--color-primary);
color: white;
text-decoration: none;
border-radius: var(--radius);
font-weight: 600;
}
@media (max-width: 768px) {
.projects-grid {
grid-template-columns: 1fr;
}
}
</style>

163
src/pages/work/[slug].astro Normal file
View File

@@ -0,0 +1,163 @@
---
import { getEmDashEntry, getEmDashCollection, decodeSlug } from "emdash";
import { Image, PortableText } from "emdash/ui";
import PortfolioBase from "../../layouts/PortfolioBase.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);
const { entries: relatedProjects } = await getEmDashCollection("projects", {
orderBy: { published_at: "desc" },
limit: 3,
});
const otherProjects = relatedProjects.filter((p) => p.id !== project.id).slice(0, 2);
const gallery = project.data.gallery as
| Array<{ url: string; alt?: string }>
| undefined;
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;
}
---
<PortfolioBase title={project.data.title} image={getImageSrc(project.data.featured_image)}>
<main>
{project.data.featured_image && (
<div class="hero">
<Image
src={project.data.featured_image}
alt={project.data.title}
width={1200}
height={675}
/>
</div>
)}
<article class="container">
<h1>{project.data.title}</h1>
<div class="content">
<PortableText value={project.data.body} />
</div>
</article>
{gallery && gallery.length > 0 && (
<div class="gallery container">
{gallery.map((img) => (
<Image
src={img.url}
alt={img.alt || ""}
width={800}
height={600}
/>
))}
</div>
)}
{otherProjects.length > 0 && (
<section class="related container">
<h2>More Work</h2>
<div class="projects">
{otherProjects.map((p) => (
<ProjectCard
title={p.data.title}
summary={p.data.summary}
featuredImage={p.data.featured_image}
href={`/work/${p.id}`}
client={p.data.client}
year={p.data.year}
categories={p.data.categories}
tags={p.data.tags}
/>
))}
</div>
</section>
)}
</main>
</PortfolioBase>
<style>
.hero {
width: 100%;
max-height: 500px;
overflow: hidden;
}
.hero img {
width: 100%;
height: 100%;
object-fit: cover;
}
article.container {
padding: var(--spacing-4xl) var(--spacing-lg);
max-width: 800px;
margin: 0 auto;
}
article h1 {
font-size: var(--font-size-4xl);
margin-bottom: var(--spacing-2xl);
font-family: var(--font-serif);
}
.content {
font-size: var(--font-size-lg);
line-height: 1.7;
}
.content :global(p) {
margin-bottom: var(--spacing-lg);
}
.gallery {
padding: var(--spacing-4xl) var(--spacing-lg);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-lg);
}
.gallery img {
width: 100%;
height: auto;
border-radius: var(--radius-lg);
}
.related {
padding: var(--spacing-5xl) var(--spacing-lg);
background: var(--color-surface);
}
.related h2 {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-2xl);
text-align: center;
font-family: var(--font-serif);
}
.related .projects {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-xl);
max-width: 900px;
margin: 0 auto;
}
@media (max-width: 768px) {
.related .projects {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -17,7 +17,7 @@
/* --- Typography --- */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, "SF Mono", monospace;
--font-serif: "Playfair Display", ui-serif, Georgia, serif;
--font-mono: ui-monospace, "SF Mono", monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;