feat: Next 16 + Payload 3 scaffold with Kaniko CI and Swarm deploy (#1)
Some checks failed
ci/woodpecker/push/web Pipeline failed
Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #1.
This commit is contained in:
27
src/app/(frontend)/about/page.tsx
Normal file
27
src/app/(frontend)/about/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
export const metadata = { title: "About" };
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-7xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-tertiary">
|
||||
02 // PROFILE
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
About
|
||||
</h1>
|
||||
<p className="max-w-3xl font-body text-xl text-on-surface-variant">
|
||||
Engineering growth through technological mastery and strategic
|
||||
leadership. Content sourced from Payload CMS (global:{" "}
|
||||
<code className="font-label text-primary">about</code>) — populated on
|
||||
first publish.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
src/app/(frontend)/contact/page.tsx
Normal file
26
src/app/(frontend)/contact/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
export const metadata = { title: "Contact" };
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-3xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-primary">
|
||||
05 // CONTACT
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
Open channel
|
||||
</h1>
|
||||
<p className="font-body text-xl text-on-surface-variant">
|
||||
Form wiring, Turnstile, and Payload submission persistence land in a
|
||||
follow-up PR (UI-06). For now, direct email lives in the Payload{" "}
|
||||
<code className="font-label text-primary">contact</code> global.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
src/app/(frontend)/globals.css
Normal file
52
src/app/(frontend)/globals.css
Normal file
@@ -0,0 +1,52 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply bg-background text-on-background font-body antialiased;
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
@apply min-h-screen selection:bg-primary/30 selection:text-on-primary-container;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* DESIGN.md: "Ghost Border" — containment that is felt rather than seen. */
|
||||
.ghost-border {
|
||||
border: 1px solid rgba(71, 72, 77, 0.15);
|
||||
}
|
||||
|
||||
/* DESIGN.md: "Glass & Gradient" — frosted terminal effect. */
|
||||
.glass-card {
|
||||
background-color: rgba(42, 44, 50, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
/* DESIGN.md: neon CTA — "lit from within" glow. */
|
||||
.neon-cta {
|
||||
background: linear-gradient(135deg, #81ecff 0%, #00e3fd 100%);
|
||||
color: #005762;
|
||||
box-shadow:
|
||||
0 0 32px 4px rgba(129, 236, 255, 0.25),
|
||||
0 0 4px 1px rgba(129, 236, 255, 0.5);
|
||||
}
|
||||
|
||||
/* DESIGN.md: technical grid background pattern. */
|
||||
.technical-grid {
|
||||
background-image: radial-gradient(
|
||||
rgba(129, 236, 255, 0.15) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
/* DESIGN.md: hero radial gradient ambient. */
|
||||
.hero-gradient {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(129, 236, 255, 0.08), transparent 40%),
|
||||
radial-gradient(circle at bottom left, rgba(216, 115, 255, 0.05), transparent 40%);
|
||||
}
|
||||
}
|
||||
57
src/app/(frontend)/layout.tsx
Normal file
57
src/app/(frontend)/layout.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Space_Grotesk, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "700"],
|
||||
variable: "--font-headline",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-body",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000",
|
||||
),
|
||||
title: {
|
||||
default: "Jason Woltje",
|
||||
template: "%s — Jason Woltje",
|
||||
},
|
||||
description:
|
||||
"A multidisciplinary architect of digital ecosystems. Engineering growth through technological mastery and strategic leadership.",
|
||||
};
|
||||
|
||||
const BUILD_SHA = process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev";
|
||||
const BUILD_REV = process.env.NEXT_PUBLIC_BUILD_REV ?? "local";
|
||||
|
||||
export default function FrontendLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`dark ${spaceGrotesk.variable} ${inter.variable}`}
|
||||
style={{ ["--font-label" as string]: "var(--font-headline)" }}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body>
|
||||
{children}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed bottom-3 right-4 z-50 font-label text-[10px] uppercase tracking-[0.2em] text-tertiary/80"
|
||||
>
|
||||
REV: {BUILD_REV} · SHA: {BUILD_SHA}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
40
src/app/(frontend)/page.tsx
Normal file
40
src/app/(frontend)/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { StatusTerminal } from "@/components/StatusTerminal";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main>
|
||||
<section className="technical-grid hero-gradient relative flex min-h-[92vh] flex-col justify-center overflow-hidden px-6">
|
||||
<StatusTerminal className="absolute left-6 top-8 md:left-12" />
|
||||
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-12 pt-20 lg:grid-cols-12">
|
||||
<div className="lg:col-span-8">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-primary">
|
||||
01 // THE MANIFESTO
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold leading-[0.9] tracking-tighter text-on-surface md:text-8xl">
|
||||
I WILL FIND
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
A WAY,
|
||||
</span>
|
||||
<br />
|
||||
OR I WILL
|
||||
<br />
|
||||
MAKE ONE.
|
||||
</h1>
|
||||
<p className="max-w-2xl font-body text-xl leading-relaxed text-on-surface-variant md:text-2xl">
|
||||
A multidisciplinary architect of digital ecosystems and
|
||||
agricultural infrastructures. Executing vision through
|
||||
precision.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
src/app/(frontend)/projects/[slug]/page.tsx
Normal file
45
src/app/(frontend)/projects/[slug]/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
type Params = { slug: string };
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return { title: slug };
|
||||
}
|
||||
|
||||
export default async function ProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
if (!slug) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-4xl px-6 py-24">
|
||||
<span className="mb-4 block font-label text-xs uppercase tracking-[0.4em] text-primary">
|
||||
PROJECT //{" "}
|
||||
<code className="text-on-surface-variant">{slug}</code>
|
||||
</span>
|
||||
<h1 className="mb-6 font-headline text-4xl font-bold tracking-tight md:text-6xl">
|
||||
Project detail
|
||||
</h1>
|
||||
<p className="font-body text-lg text-on-surface-variant">
|
||||
Slug: <code className="font-label text-primary">{slug}</code>. Body
|
||||
will render from the Payload{" "}
|
||||
<code className="font-label text-primary">projects</code> collection
|
||||
once wired.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
src/app/(frontend)/projects/page.tsx
Normal file
26
src/app/(frontend)/projects/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
export const metadata = { title: "Projects" };
|
||||
|
||||
export default function ProjectsIndexPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-7xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-primary">
|
||||
03 // PROJECTS
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
Strategic Verticals
|
||||
</h1>
|
||||
<p className="max-w-3xl font-body text-xl text-on-surface-variant">
|
||||
Content sourced from Payload CMS collection{" "}
|
||||
<code className="font-label text-primary">projects</code> — rendered
|
||||
here once the bento-grid section component lands in a follow-up PR.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
src/app/(frontend)/resume/page.tsx
Normal file
28
src/app/(frontend)/resume/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
export const metadata = { title: "Resume" };
|
||||
|
||||
export default function ResumePage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-4xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-tertiary">
|
||||
06 // CURRICULUM
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
Resume
|
||||
</h1>
|
||||
<p className="font-body text-xl text-on-surface-variant">
|
||||
Sourced from Payload{" "}
|
||||
<code className="font-label text-primary">resume</code> global. PDF
|
||||
export wires up at{" "}
|
||||
<code className="font-label text-primary">/resume.pdf</code> in
|
||||
UI-07.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
src/app/(frontend)/writing/[slug]/page.tsx
Normal file
43
src/app/(frontend)/writing/[slug]/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
type Params = { slug: string };
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return { title: slug };
|
||||
}
|
||||
|
||||
export default async function PostDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<Params>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
if (!slug) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<article className="mx-auto max-w-3xl px-6 py-24">
|
||||
<span className="mb-4 block font-label text-xs uppercase tracking-[0.4em] text-secondary">
|
||||
POST //{" "}
|
||||
<code className="text-on-surface-variant">{slug}</code>
|
||||
</span>
|
||||
<h1 className="mb-6 font-headline text-4xl font-bold tracking-tight md:text-6xl">
|
||||
Post detail
|
||||
</h1>
|
||||
<p className="font-body text-lg text-on-surface-variant">
|
||||
Body renders from Payload{" "}
|
||||
<code className="font-label text-primary">posts</code> once wired.
|
||||
</p>
|
||||
</article>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/app/(frontend)/writing/page.tsx
Normal file
25
src/app/(frontend)/writing/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SiteHeader } from "@/components/SiteHeader";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
|
||||
export const metadata = { title: "Writing" };
|
||||
|
||||
export default function WritingIndexPage() {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto max-w-7xl px-6 py-24">
|
||||
<span className="mb-6 block font-label text-xs uppercase tracking-[0.4em] text-secondary">
|
||||
04 // WRITING
|
||||
</span>
|
||||
<h1 className="mb-8 font-headline text-5xl font-bold tracking-tighter md:text-7xl">
|
||||
Signal
|
||||
</h1>
|
||||
<p className="max-w-3xl font-body text-xl text-on-surface-variant">
|
||||
Long-form from the Payload{" "}
|
||||
<code className="font-label text-primary">posts</code> collection.
|
||||
</p>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
18
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { Metadata } from "next";
|
||||
import config from "@payload-config";
|
||||
import { NotFoundPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params: params as any, searchParams: searchParams as any });
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, params: params as any, searchParams: searchParams as any, importMap });
|
||||
|
||||
export default NotFound;
|
||||
18
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
18
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { Metadata } from "next";
|
||||
import config from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params: params as any, searchParams: searchParams as any });
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params: params as any, searchParams: searchParams as any, importMap });
|
||||
|
||||
export default Page;
|
||||
2
src/app/(payload)/admin/importMap.js
Normal file
2
src/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// Auto-generated stub. Regenerate with `pnpm generate:importmap` after adding custom components.
|
||||
export const importMap = {};
|
||||
16
src/app/(payload)/api/[...slug]/route.ts
Normal file
16
src/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import config from "@payload-config";
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
4
src/app/(payload)/api/graphql-playground/route.ts
Normal file
4
src/app/(payload)/api/graphql-playground/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import config from "@payload-config";
|
||||
import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config);
|
||||
5
src/app/(payload)/api/graphql/route.ts
Normal file
5
src/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import config from "@payload-config";
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
16
src/app/(payload)/api/health/route.ts
Normal file
16
src/app/(payload)/api/health/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "ok",
|
||||
buildSha: process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev",
|
||||
buildRev: process.env.NEXT_PUBLIC_BUILD_REV ?? "local",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
1
src/app/(payload)/custom.scss
Normal file
1
src/app/(payload)/custom.scss
Normal file
@@ -0,0 +1 @@
|
||||
/* Payload admin UI customizations. Keep minimal for v0.0.x. */
|
||||
22
src/app/(payload)/layout.tsx
Normal file
22
src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/* Payload admin layout — do not modify unless you understand the Payload 3 requirements. */
|
||||
import type { ServerFunctionClient } from "payload";
|
||||
import config from "@payload-config";
|
||||
import { RootLayout, handleServerFunctions } from "@payloadcms/next/layouts";
|
||||
import "@payloadcms/next/css";
|
||||
import { importMap } from "./admin/importMap";
|
||||
import "./custom.scss";
|
||||
|
||||
type Args = { children: React.ReactNode };
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
"use server";
|
||||
return handleServerFunctions({ ...args, config, importMap });
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
21
src/collections/Categories.ts
Normal file
21
src/collections/Categories.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
slug: "categories",
|
||||
access: { read: () => true },
|
||||
admin: { useAsTitle: "name", defaultColumns: ["name", "slug", "accent"] },
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true, index: true },
|
||||
{
|
||||
name: "accent",
|
||||
type: "select",
|
||||
defaultValue: "primary",
|
||||
options: [
|
||||
{ label: "Primary (cyan)", value: "primary" },
|
||||
{ label: "Secondary (magenta)", value: "secondary" },
|
||||
{ label: "Tertiary (green)", value: "tertiary" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
33
src/collections/ContactSubmissions.ts
Normal file
33
src/collections/ContactSubmissions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const ContactSubmissions: CollectionConfig = {
|
||||
slug: "contactSubmissions",
|
||||
access: {
|
||||
read: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
create: () => true,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "status", "submittedAt"],
|
||||
},
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{ name: "email", type: "email", required: true },
|
||||
{ name: "brief", type: "textarea", required: true },
|
||||
{ name: "source", type: "text" },
|
||||
{ name: "submittedAt", type: "date", defaultValue: () => new Date() },
|
||||
{ name: "ipHash", type: "text" },
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "new",
|
||||
options: [
|
||||
{ label: "New", value: "new" },
|
||||
{ label: "Replied", value: "replied" },
|
||||
{ label: "Spam", value: "spam" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
36
src/collections/Gear.ts
Normal file
36
src/collections/Gear.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Gear: CollectionConfig = {
|
||||
slug: "gear",
|
||||
access: { read: () => true },
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "type"],
|
||||
description: "Music / maker gear — decorative only for v0.0.x",
|
||||
},
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Daily driver", value: "daily-driver" },
|
||||
{ label: "Interface", value: "interface" },
|
||||
{ label: "Monitor", value: "monitor" },
|
||||
{ label: "Workbench", value: "workbench" },
|
||||
],
|
||||
},
|
||||
{ name: "notes", type: "textarea" },
|
||||
{ name: "image", type: "upload", relationTo: "media" },
|
||||
{
|
||||
name: "accent",
|
||||
type: "select",
|
||||
defaultValue: "tertiary",
|
||||
options: [
|
||||
{ label: "Primary", value: "primary" },
|
||||
{ label: "Secondary", value: "secondary" },
|
||||
{ label: "Tertiary", value: "tertiary" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
26
src/collections/Media.ts
Normal file
26
src/collections/Media.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: "media",
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "alt",
|
||||
},
|
||||
upload: {
|
||||
staticDir: "media",
|
||||
imageSizes: [
|
||||
{ name: "thumb", width: 400, height: 400, position: "centre" },
|
||||
{ name: "card", width: 800, height: undefined, position: "centre" },
|
||||
{ name: "hero", width: 1600, height: undefined, position: "centre" },
|
||||
{ name: "og", width: 1200, height: 630, position: "centre" },
|
||||
],
|
||||
adminThumbnail: "thumb",
|
||||
mimeTypes: ["image/*"],
|
||||
},
|
||||
fields: [
|
||||
{ name: "alt", type: "text", required: true },
|
||||
{ name: "credit", type: "text" },
|
||||
],
|
||||
};
|
||||
43
src/collections/Posts.ts
Normal file
43
src/collections/Posts.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: "posts",
|
||||
access: {
|
||||
read: ({ req: { user } }) =>
|
||||
user ? true : { status: { equals: "published" } },
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "publishedAt"],
|
||||
},
|
||||
versions: { drafts: true },
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true, index: true },
|
||||
{ name: "excerpt", type: "textarea" },
|
||||
{ name: "body", type: "richText" },
|
||||
{ name: "category", type: "relationship", relationTo: "categories" },
|
||||
{ name: "heroImage", type: "upload", relationTo: "media" },
|
||||
{ name: "tags", type: "array", fields: [{ name: "tag", type: "text" }] },
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "draft",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Published", value: "published" },
|
||||
],
|
||||
},
|
||||
{ name: "publishedAt", type: "date" },
|
||||
{
|
||||
name: "seo",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "title", type: "text" },
|
||||
{ name: "description", type: "textarea" },
|
||||
{ name: "image", type: "upload", relationTo: "media" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
64
src/collections/Projects.ts
Normal file
64
src/collections/Projects.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Projects: CollectionConfig = {
|
||||
slug: "projects",
|
||||
access: {
|
||||
read: ({ req: { user } }) =>
|
||||
user ? true : { status: { equals: "published" } },
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "featured", "order", "publishedAt"],
|
||||
},
|
||||
versions: { drafts: true },
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true, index: true },
|
||||
{
|
||||
name: "role",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Founder / CEO", value: "founder" },
|
||||
{ label: "Consultant", value: "consultant" },
|
||||
{ label: "Engineer", value: "engineer" },
|
||||
{ label: "Infrastructure", value: "infra" },
|
||||
],
|
||||
},
|
||||
{ name: "category", type: "relationship", relationTo: "categories" },
|
||||
{ name: "summary", type: "textarea" },
|
||||
{ name: "body", type: "richText" },
|
||||
{ name: "stack", type: "array", fields: [{ name: "name", type: "text" }] },
|
||||
{ name: "heroImage", type: "upload", relationTo: "media" },
|
||||
{
|
||||
name: "gallery",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "image", type: "upload", relationTo: "media", required: true },
|
||||
{ name: "caption", type: "text" },
|
||||
],
|
||||
},
|
||||
{ name: "externalUrl", type: "text" },
|
||||
{ name: "featured", type: "checkbox", defaultValue: false },
|
||||
{ name: "order", type: "number", defaultValue: 0 },
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "draft",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Published", value: "published" },
|
||||
],
|
||||
},
|
||||
{ name: "publishedAt", type: "date" },
|
||||
{
|
||||
name: "seo",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "title", type: "text" },
|
||||
{ name: "description", type: "textarea" },
|
||||
{ name: "image", type: "upload", relationTo: "media" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
21
src/collections/Users.ts
Normal file
21
src/collections/Users.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: "users",
|
||||
admin: {
|
||||
useAsTitle: "email",
|
||||
defaultColumns: ["email", "role"],
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
{
|
||||
name: "role",
|
||||
type: "select",
|
||||
defaultValue: "admin",
|
||||
options: [{ label: "Admin", value: "admin" }],
|
||||
access: {
|
||||
update: ({ req: { user } }) => user?.role === "admin",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
14
src/components/SiteFooter.tsx
Normal file
14
src/components/SiteFooter.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="mt-24 border-t border-outline-variant/10 bg-surface-container-lowest">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-6 py-12 md:flex-row md:items-center md:justify-between">
|
||||
<div className="font-headline text-sm uppercase tracking-[0.2em] text-on-surface-variant">
|
||||
© {new Date().getFullYear()} Jason Woltje · All rights reserved
|
||||
</div>
|
||||
<div className="font-label text-[10px] uppercase tracking-[0.2em] text-tertiary/80">
|
||||
LATENCY: 42ms · CORE STATUS: NOMINAL
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
35
src/components/SiteHeader.tsx
Normal file
35
src/components/SiteHeader.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const links = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Projects", href: "/projects" },
|
||||
{ label: "Writing", href: "/writing" },
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
];
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full bg-background/80 backdrop-blur-xl">
|
||||
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-headline text-xl font-bold uppercase tracking-tighter text-primary"
|
||||
>
|
||||
JASON WOLTJE
|
||||
</Link>
|
||||
<div className="hidden items-center gap-8 md:flex">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="font-label text-[14px] uppercase tracking-tighter text-on-surface-variant transition-colors hover:text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
23
src/components/StatusTerminal.tsx
Normal file
23
src/components/StatusTerminal.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
const BUILD_SHA = process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev";
|
||||
const BUILD_REV = process.env.NEXT_PUBLIC_BUILD_REV ?? "local";
|
||||
|
||||
type Props = {
|
||||
location?: string;
|
||||
status?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function StatusTerminal({
|
||||
location = "39.0997° N, 94.5786° W",
|
||||
status = "ONLINE",
|
||||
className = "",
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<span className="flex h-2 w-2 rounded-full bg-tertiary shadow-[0_0_8px_#8eff71]" />
|
||||
<span className="font-label text-[10px] uppercase tracking-[0.2em] text-tertiary">
|
||||
LOC: {location} · STATUS: {status} · REV: {BUILD_REV} · SHA: {BUILD_SHA}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/globals/About.ts
Normal file
27
src/globals/About.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const About: GlobalConfig = {
|
||||
slug: "about",
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: "intro", type: "richText" },
|
||||
{ name: "makerMindset", type: "richText" },
|
||||
{ name: "soundtrack", type: "richText" },
|
||||
{
|
||||
name: "gearRefs",
|
||||
type: "relationship",
|
||||
relationTo: "gear",
|
||||
hasMany: true,
|
||||
},
|
||||
{
|
||||
name: "timeline",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "year", type: "text", required: true },
|
||||
{ name: "title", type: "text", required: true },
|
||||
{ name: "note", type: "textarea" },
|
||||
],
|
||||
},
|
||||
{ name: "portrait", type: "upload", relationTo: "media" },
|
||||
],
|
||||
};
|
||||
33
src/globals/Contact.ts
Normal file
33
src/globals/Contact.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const Contact: GlobalConfig = {
|
||||
slug: "contact",
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{
|
||||
name: "availabilityBadge",
|
||||
type: "text",
|
||||
defaultValue: "Accepting new inquiries",
|
||||
},
|
||||
{ name: "timezoneLabel", type: "text", defaultValue: "America/Chicago" },
|
||||
{ name: "directEmail", type: "email" },
|
||||
{
|
||||
name: "socialLinks",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "label", type: "text", required: true },
|
||||
{ name: "href", type: "text", required: true },
|
||||
{ name: "icon", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "newsletterEnabled",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
"Enable the newsletter subscribe UI. Keep false until Mautic is deployed.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
35
src/globals/Home.ts
Normal file
35
src/globals/Home.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const Home: GlobalConfig = {
|
||||
slug: "home",
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: "heroPrefix", type: "text", defaultValue: "01 // THE MANIFESTO" },
|
||||
{ name: "heroHeadline", type: "richText" },
|
||||
{ name: "heroSub", type: "textarea" },
|
||||
{
|
||||
name: "ctas",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "label", type: "text", required: true },
|
||||
{ name: "href", type: "text", required: true },
|
||||
{
|
||||
name: "style",
|
||||
type: "select",
|
||||
defaultValue: "primary",
|
||||
options: [
|
||||
{ label: "Primary (neon)", value: "primary" },
|
||||
{ label: "Secondary", value: "secondary" },
|
||||
{ label: "Ghost", value: "ghost" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "featuredProjects",
|
||||
type: "relationship",
|
||||
relationTo: "projects",
|
||||
hasMany: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
21
src/globals/Navigation.ts
Normal file
21
src/globals/Navigation.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const Navigation: GlobalConfig = {
|
||||
slug: "navigation",
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{
|
||||
name: "primaryLinks",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "label", type: "text", required: true },
|
||||
{ name: "href", type: "text", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "footerStatusText",
|
||||
type: "text",
|
||||
defaultValue: "LATENCY: 42ms | CORE STATUS: NOMINAL",
|
||||
},
|
||||
],
|
||||
};
|
||||
46
src/globals/Resume.ts
Normal file
46
src/globals/Resume.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const Resume: GlobalConfig = {
|
||||
slug: "resume",
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: "summary", type: "textarea" },
|
||||
{
|
||||
name: "experience",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "company", type: "text", required: true },
|
||||
{ name: "role", type: "text", required: true },
|
||||
{ name: "startDate", type: "date" },
|
||||
{ name: "endDate", type: "date" },
|
||||
{ name: "current", type: "checkbox", defaultValue: false },
|
||||
{
|
||||
name: "bullets",
|
||||
type: "array",
|
||||
fields: [{ name: "text", type: "textarea" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "skills",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "category", type: "text", required: true },
|
||||
{
|
||||
name: "items",
|
||||
type: "array",
|
||||
fields: [{ name: "name", type: "text" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "education",
|
||||
type: "array",
|
||||
fields: [
|
||||
{ name: "institution", type: "text", required: true },
|
||||
{ name: "credential", type: "text" },
|
||||
{ name: "year", type: "text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
25
src/globals/SEO.ts
Normal file
25
src/globals/SEO.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const SEO: GlobalConfig = {
|
||||
slug: "seo",
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: "siteTitle", type: "text", defaultValue: "Jason Woltje" },
|
||||
{
|
||||
name: "defaultDescription",
|
||||
type: "textarea",
|
||||
defaultValue:
|
||||
"A multidisciplinary architect of digital ecosystems. Engineering growth through technological mastery and strategic leadership.",
|
||||
},
|
||||
{ name: "defaultOgImage", type: "upload", relationTo: "media" },
|
||||
{ name: "twitterHandle", type: "text" },
|
||||
{
|
||||
name: "jsonLdPerson",
|
||||
type: "json",
|
||||
admin: {
|
||||
description:
|
||||
"Schema.org Person JSON-LD. Injected verbatim into the home page <head>.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
66
src/payload.config.ts
Normal file
66
src/payload.config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildConfig } from "payload";
|
||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { Users } from "@/collections/Users";
|
||||
import { Media } from "@/collections/Media";
|
||||
import { Categories } from "@/collections/Categories";
|
||||
import { Projects } from "@/collections/Projects";
|
||||
import { Posts } from "@/collections/Posts";
|
||||
import { Gear } from "@/collections/Gear";
|
||||
import { ContactSubmissions } from "@/collections/ContactSubmissions";
|
||||
|
||||
import { Home } from "@/globals/Home";
|
||||
import { About } from "@/globals/About";
|
||||
import { Contact } from "@/globals/Contact";
|
||||
import { Resume } from "@/globals/Resume";
|
||||
import { Navigation } from "@/globals/Navigation";
|
||||
import { SEO } from "@/globals/SEO";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
|
||||
secret: process.env.PAYLOAD_SECRET || "",
|
||||
|
||||
admin: {
|
||||
user: "users",
|
||||
meta: {
|
||||
titleSuffix: "— Jason Woltje",
|
||||
},
|
||||
},
|
||||
|
||||
editor: lexicalEditor({}),
|
||||
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URI,
|
||||
},
|
||||
}),
|
||||
|
||||
collections: [
|
||||
Users,
|
||||
Media,
|
||||
Categories,
|
||||
Projects,
|
||||
Posts,
|
||||
Gear,
|
||||
ContactSubmissions,
|
||||
],
|
||||
|
||||
globals: [Home, About, Contact, Resume, Navigation, SEO],
|
||||
|
||||
sharp,
|
||||
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, "payload-types.ts"),
|
||||
},
|
||||
|
||||
graphQL: {
|
||||
schemaOutputFile: path.resolve(dirname, "generated-schema.graphql"),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user