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:
154
.astro/content.d.ts
vendored
Normal file
154
.astro/content.d.ts
vendored
Normal 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
4
.astro/fonts.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'astro:assets' {
|
||||
/** @internal */
|
||||
export type CssVariable = (["--font-emdash"])[number];
|
||||
}
|
||||
3
.astro/types.d.ts
vendored
Normal file
3
.astro/types.d.ts
vendored
Normal 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
191
.omc/project-memory.json
Normal 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": []
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
7201
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
358
seed/seed.json
358
seed/seed.json
@@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
113
src/components/ProjectCard.astro
Normal file
113
src/components/ProjectCard.astro
Normal 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>
|
||||
160
src/layouts/PortfolioBase.astro
Normal file
160
src/layouts/PortfolioBase.astro
Normal 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>© {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
65
src/pages/about.astro
Normal 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>
|
||||
65
src/pages/portfolio-about.astro
Normal file
65
src/pages/portfolio-about.astro
Normal 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>
|
||||
160
src/pages/portfolio-contact.astro
Normal file
160
src/pages/portfolio-contact.astro
Normal 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>
|
||||
105
src/pages/portfolio-index.astro
Normal file
105
src/pages/portfolio-index.astro
Normal 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
163
src/pages/work/[slug].astro
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user