feat: redesign homepage with Radix UI and add dev environment defaults

- Replace title/tagline homepage with social profile design
- Add Radix UI Themes + icon libraries for modern UI components
- Provide sensible dev defaults for DB/secrets (no .env required)
- Add production safety checks for critical env vars
- Make optional features (cron, healthcheck) gracefully skip when unconfigured
This commit is contained in:
2026-01-01 23:41:35 -06:00
parent da366b9538
commit ac7618f2fd
14 changed files with 5364 additions and 6468 deletions
+10 -12
View File
@@ -1,25 +1,23 @@
# Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. # Environment variables for xevion.dev
# Keep this file up-to-date when you add new variables to `.env`. # Dev defaults are available - no .env file needed for basic development
# See schema in /env/schema.mjs
# This file will be committed to version control, so make sure not to have any secrets in it. # Payload CMS (dev defaults match docker-compose credentials)
# If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets.
# When adding additional env variables, the schema in /env/schema.mjs should be updated accordingly
# Payload CMS
PAYLOAD_SECRET=your-secret-key-here PAYLOAD_SECRET=your-secret-key-here
DATABASE_URI=postgresql://xevion:xevion_dev_password@localhost:5432/xevion_dev DATABASE_URI=postgresql://xevion:xevion_dev_password@localhost:5432/xevion_dev
PAYLOAD_REVALIDATE_KEY=your-revalidate-key-here
# GitHub API (for cron job) # Optional - enables /api/cron/updated endpoint
GITHUB_API_TOKEN= GITHUB_API_TOKEN=
# API Secrets # Optional - enables /api/healthcheck endpoint
HEALTHCHECK_SECRET= HEALTHCHECK_SECRET=
# Optional - auth for /api/cron/updated (skipped in dev)
CRON_SECRET= CRON_SECRET=
# Optional # Optional
PAYLOAD_REVALIDATE_KEY=
TITLE= TITLE=
# Node environment # Auto-detected, usually don't need to set
NODE_ENV=development NODE_ENV=development
+11
View File
@@ -5,6 +5,17 @@ This is the newest iteration of my personal website.
Instead of focus on playing around or showing off blog posts, this site will focus on presentation, Instead of focus on playing around or showing off blog posts, this site will focus on presentation,
as a portfolio of what I have learned and what I can do. as a portfolio of what I have learned and what I can do.
## Development
Start the database and dev server:
```bash
pnpm db:start
pnpm dev
```
No `.env` file needed for basic development - sensible defaults are provided. Optional features require environment variables (see `.env.example`).
## Stack ## Stack
- Hosted by [Vercel][vercel] - Hosted by [Vercel][vercel]
+9 -1
View File
@@ -7,13 +7,18 @@
"build": "next build", "build": "next build",
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"lint": "eslint .", "lint": "eslint .",
"start": "next start" "start": "next start",
"db:start": "docker compose up -d",
"db:stop": "docker compose down",
"db:reset": "docker compose down -v && docker compose up -d"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.1.0", "@fontsource-variable/inter": "^5.1.0",
"@fontsource-variable/roboto": "^5.1.0", "@fontsource-variable/roboto": "^5.1.0",
"@fontsource-variable/roboto-mono": "^5.1.0", "@fontsource-variable/roboto-mono": "^5.1.0",
"@fontsource/hanken-grotesk": "^5.1.0", "@fontsource/hanken-grotesk": "^5.1.0",
"@fontsource/schibsted-grotesk": "^5.2.8",
"@icons-pack/react-simple-icons": "^13.8.0",
"@mantine/hooks": "^8", "@mantine/hooks": "^8",
"@next/eslint-plugin-next": "^15.1.1", "@next/eslint-plugin-next": "^15.1.1",
"@octokit/core": "^7.0.5", "@octokit/core": "^7.0.5",
@@ -21,6 +26,7 @@
"@payloadcms/next": "^3.61.1", "@payloadcms/next": "^3.61.1",
"@payloadcms/payload-cloud": "^3.61.1", "@payloadcms/payload-cloud": "^3.61.1",
"@payloadcms/richtext-lexical": "^3.61.1", "@payloadcms/richtext-lexical": "^3.61.1",
"@radix-ui/themes": "^3.2.1",
"@tanstack/react-query": "^5.90", "@tanstack/react-query": "^5.90",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -30,8 +36,10 @@
"next": "^15.5.6", "next": "^15.5.6",
"p5i": "^0.6.0", "p5i": "^0.6.0",
"payload": "^3.61.1", "payload": "^3.61.1",
"radix-ui": "^1.4.3",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-wrap-balancer": "^1", "react-wrap-balancer": "^1",
"sharp": "^0.34", "sharp": "^0.34",
+5172 -6379
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -5,7 +5,11 @@ import "@fontsource-variable/inter";
import "@fontsource-variable/roboto"; import "@fontsource-variable/roboto";
import "@fontsource-variable/roboto-mono"; import "@fontsource-variable/roboto-mono";
import "@fontsource/hanken-grotesk/900.css"; import "@fontsource/hanken-grotesk/900.css";
import "@fontsource/schibsted-grotesk/400.css";
import "@fontsource/schibsted-grotesk/500.css";
import "@fontsource/schibsted-grotesk/600.css";
import "@radix-ui/themes/styles.css";
import "@/styles/globals.css"; import "@/styles/globals.css";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Providers } from "./providers"; import { Providers } from "./providers";
+78 -58
View File
@@ -1,68 +1,88 @@
import AppWrapper from "@/components/AppWrapper"; import AppWrapper from "@/components/AppWrapper";
import { getPayload } from "payload"; import { Flex, Button, Text, Container, Box } from "@radix-ui/themes";
import config from "../../payload.config";
import Link from "next/link"; import Link from "next/link";
import Balancer from "react-wrap-balancer"; import { SiGithub, IconType } from "@icons-pack/react-simple-icons";
import { SiLinkedin } from "react-icons/si";
export const dynamic = "force-dynamic"; // Don't prerender at build time function SocialLink({
href,
type Metadata = { icon: IconComponent,
tagline: string; children,
resume: { }: {
id: string; href: string;
url: string; icon: React.ElementType;
filename: string; children: React.ReactNode;
}; }) {
resumeFilename?: string; return (
}; <Link href={href} className="border-b border-(--gray-7)">
<Flex align="center" className="gap-x-1.5">
<IconComponent className="size-5 text-(--gray-11)" />
<Text className="text-(--gray-12)">{children}</Text>
</Flex>
</Link>
);
}
export default async function HomePage() { export default async function HomePage() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
// @ts-ignore - Globals will be typed after first database connection
const metadata = (await payload.findGlobal({
slug: "metadata",
})) as Metadata;
const title = process.env.TITLE ?? "Xevion";
const resumeUrl = metadata.resume?.url ?? "#";
const buttons = [
{ text: "GitHub", href: "https://github.com/Xevion" },
{ text: "Projects", href: "/projects" },
{ text: "Blog", href: "https://undefined.behavio.rs" },
{ text: "Contact", href: "/contact" },
{ text: "Resume", href: resumeUrl },
];
return ( return (
<AppWrapper className="overflow-x-hidden" dotsClassName="animate-bg"> <AppWrapper
<div className="flex h-screen w-screen items-center justify-center overflow-hidden"> className="overflow-x-hidden font-schibsted"
<div className="relative z-10 flex w-full flex-col items-center justify-start"> dotsClassName="animate-bg"
<nav className="z-10 animate-fade-in">
<ul className="flex items-center justify-center gap-4">
{buttons.map(({ text, href }) => (
<Link
key={href}
className="text-sm text-zinc-500 duration-500 hover:text-zinc-300"
href={href}
> >
{text} <Flex
</Link> direction="row"
))} justify="between"
</ul> align="center"
</nav> width="100%"
<div className="animate-glow hidden h-px w-screen animate-fade-left bg-gradient-to-r from-zinc-300/0 via-zinc-300/50 to-zinc-300/0 md:block" /> pt="5"
<h1 className="text-edge-outline font-display my-3.5 animate-title select-none whitespace-nowrap bg-white bg-clip-text font-hanken text-5xl uppercase text-transparent drop-shadow-extreme duration-1000 sm:text-6xl md:text-9xl lg:text-10xl"> px="6"
{title} pb="9"
</h1> >
<div className="animate-glow hidden h-px w-screen animate-fade-right bg-gradient-to-r from-zinc-300/0 via-zinc-300/50 to-zinc-300/0 md:block" /> <Flex direction="column">
<div className="max-w-screen-sm animate-fade-in text-center text-sm text-zinc-500 sm:text-base"> <Text size="6" weight="bold" highContrast>
<Balancer>{metadata.tagline}</Balancer> Ryan Walters,
</div> </Text>
</div> <Text
</div> size="6"
weight="regular"
style={{
color: "var(--gray-11)",
}}
>
Software Engineer
</Text>
</Flex>
<Text size="6" className="font-semibold" highContrast>
About
</Text>
</Flex>
<Flex align="center" direction="column">
<Box className="max-w-2xl mx-6 border-y border-(--gray-7) divide-y divide-(--gray-7)">
<Box py="4" className="text-(--gray-12)">
<Text style={{ fontSize: "0.95em" }}>
A fanatical software engineer with expertise and passion for
sound, scalable and high-performance applications. I'm always
working on something new. <br />
Sometimes innovative &mdash; sometimes crazy.
</Text>
</Box>
<Box py="3">
<Text>Find me on</Text>
<Flex gapX="2" pl="3" pt="3" pb="2">
<SocialLink href="https://github.com/ryanwalters" icon={SiGithub}>
GitHub
</SocialLink>
<SocialLink
href="https://linkedin.com/in/ryancwalters"
icon={SiLinkedin}
>
LinkedIn
</SocialLink>
</Flex>
</Box>
</Box>
</Flex>
</AppWrapper> </AppWrapper>
); );
} }
+4
View File
@@ -2,12 +2,16 @@
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
import { Provider as BalancerProvider } from "react-wrap-balancer"; import { Provider as BalancerProvider } from "react-wrap-balancer";
import { Theme } from "@radix-ui/themes";
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
// @ts-expect-error - Radix UI Themes has React 19 type compatibility issues
<Theme appearance="dark">
<BalancerProvider> <BalancerProvider>
{children} {children}
<Analytics /> <Analytics />
</BalancerProvider> </BalancerProvider>
</Theme>
); );
} }
+5 -1
View File
@@ -1 +1,5 @@
export const importMap = {};
export const importMap = {
}
+14 -3
View File
@@ -181,14 +181,25 @@ async function handleProject({
export async function GET(req: Request) { export async function GET(req: Request) {
const { CRON_SECRET, GITHUB_API_TOKEN } = process.env; const { CRON_SECRET, GITHUB_API_TOKEN } = process.env;
if (!CRON_SECRET || !GITHUB_API_TOKEN) {
if (!GITHUB_API_TOKEN) {
return NextResponse.json( return NextResponse.json(
{ error: "Missing environment variables" }, {
{ status: 500 }, error: "Service unavailable",
message: "GITHUB_API_TOKEN not configured",
},
{ status: 503 },
); );
} }
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
if (!CRON_SECRET) {
return NextResponse.json(
{ error: "Server misconfiguration" },
{ status: 500 },
);
}
const authHeader = req.headers.get("authorization"); const authHeader = req.headers.get("authorization");
const url = new URL(req.url); const url = new URL(req.url);
const secretQueryParam = url.searchParams.get("secret"); const secretQueryParam = url.searchParams.get("secret");
+12 -2
View File
@@ -3,10 +3,20 @@ import config from "../../../payload.config";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export async function GET(req: Request) { export async function GET(req: Request) {
const secret = req.headers.get("authorization");
const healthcheckSecret = process.env.HEALTHCHECK_SECRET; const healthcheckSecret = process.env.HEALTHCHECK_SECRET;
if (!secret || !healthcheckSecret || secret !== healthcheckSecret) { if (!healthcheckSecret) {
return NextResponse.json(
{
error: "Service unavailable",
message: "HEALTHCHECK_SECRET not configured",
},
{ status: 503 },
);
}
const secret = req.headers.get("authorization");
if (secret !== healthcheckSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
+11 -5
View File
@@ -6,12 +6,18 @@ import { z } from "zod";
* This way you can ensure the app isn't built with invalid env vars. * This way you can ensure the app isn't built with invalid env vars.
*/ */
export const serverSchema = z.object({ export const serverSchema = z.object({
CRON_SECRET: z.string().nullish(), CRON_SECRET: z.string().optional(),
GITHUB_API_TOKEN: z.string(), GITHUB_API_TOKEN: z.string().optional(),
PAYLOAD_SECRET: z.string(), PAYLOAD_SECRET: z
DATABASE_URI: z.string(), .string()
.default("dev-secret-change-in-production-immediately"),
DATABASE_URI: z
.string()
.default(
"postgresql://xevion:xevion_dev_password@localhost:5432/xevion_dev",
),
PAYLOAD_REVALIDATE_KEY: z.string().optional(), PAYLOAD_REVALIDATE_KEY: z.string().optional(),
HEALTHCHECK_SECRET: z.string(), HEALTHCHECK_SECRET: z.string().optional(),
NODE_ENV: z.enum(["development", "test", "production"]), NODE_ENV: z.enum(["development", "test", "production"]),
TITLE: z.preprocess((value) => { TITLE: z.preprocess((value) => {
if (value === undefined || value === "") return null; if (value === undefined || value === "") return null;
+25
View File
@@ -24,4 +24,29 @@ for (let key of Object.keys(_serverEnv.data)) {
} }
} }
// Production safety checks
if (process.env.NODE_ENV === "production") {
if (
_serverEnv.data.PAYLOAD_SECRET ===
"dev-secret-change-in-production-immediately"
) {
throw new Error("PAYLOAD_SECRET must be explicitly set in production");
}
if (_serverEnv.data.DATABASE_URI?.includes("xevion_dev_password")) {
throw new Error("DATABASE_URI must be explicitly set in production");
}
}
// Development warnings for missing optional secrets
if (process.env.NODE_ENV !== "production") {
const missing = [];
if (!_serverEnv.data.GITHUB_API_TOKEN) missing.push("GITHUB_API_TOKEN");
if (!_serverEnv.data.HEALTHCHECK_SECRET) missing.push("HEALTHCHECK_SECRET");
if (!_serverEnv.data.CRON_SECRET) missing.push("CRON_SECRET");
if (missing.length > 0) {
console.warn(`Environment variables missing: [${missing.join(", ")}]`);
}
}
export const env = { ..._serverEnv.data, ...clientEnv }; export const env = { ..._serverEnv.data, ...clientEnv };
+3 -2
View File
@@ -13,6 +13,7 @@ import { Projects } from "./collections/Projects";
import { Technologies } from "./collections/Technologies"; import { Technologies } from "./collections/Technologies";
import { Links } from "./collections/Links"; import { Links } from "./collections/Links";
import { Metadata } from "./globals/Metadata"; import { Metadata } from "./globals/Metadata";
import { env } from "./env/server.mjs";
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename); const dirname = path.dirname(filename);
@@ -24,13 +25,13 @@ export default buildConfig({
collections: [Users, Media, Projects, Technologies, Links], collections: [Users, Media, Projects, Technologies, Links],
globals: [Metadata], globals: [Metadata],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || "", secret: env.PAYLOAD_SECRET,
typescript: { typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"), outputFile: path.resolve(dirname, "payload-types.ts"),
}, },
db: postgresAdapter({ db: postgresAdapter({
pool: { pool: {
connectionString: process.env.DATABASE_URI || "", connectionString: env.DATABASE_URI,
}, },
}), }),
sharp, sharp,
+1
View File
@@ -15,6 +15,7 @@
--font-roboto: "Roboto", sans-serif; --font-roboto: "Roboto", sans-serif;
--font-mono: "Roboto Mono", monospace; --font-mono: "Roboto Mono", monospace;
--font-hanken: "Hanken Grotesk", sans-serif; --font-hanken: "Hanken Grotesk", sans-serif;
--font-schibsted: "Schibsted Grotesk", sans-serif;
/* Background images */ /* Background images */
--background-image-gradient-radial: radial-gradient( --background-image-gradient-radial: radial-gradient(