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.
# Keep this file up-to-date when you add new variables to `.env`.
# Environment variables for xevion.dev
# 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.
# 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 CMS (dev defaults match docker-compose credentials)
PAYLOAD_SECRET=your-secret-key-here
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=
# API Secrets
# Optional - enables /api/healthcheck endpoint
HEALTHCHECK_SECRET=
# Optional - auth for /api/cron/updated (skipped in dev)
CRON_SECRET=
# Optional
PAYLOAD_REVALIDATE_KEY=
TITLE=
# Node environment
# Auto-detected, usually don't need to set
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,
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
- Hosted by [Vercel][vercel]
+9 -1
View File
@@ -7,13 +7,18 @@
"build": "next build",
"dev": "next dev --turbopack",
"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": {
"@fontsource-variable/inter": "^5.1.0",
"@fontsource-variable/roboto": "^5.1.0",
"@fontsource-variable/roboto-mono": "^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",
"@next/eslint-plugin-next": "^15.1.1",
"@octokit/core": "^7.0.5",
@@ -21,6 +26,7 @@
"@payloadcms/next": "^3.61.1",
"@payloadcms/payload-cloud": "^3.61.1",
"@payloadcms/richtext-lexical": "^3.61.1",
"@radix-ui/themes": "^3.2.1",
"@tanstack/react-query": "^5.90",
"@vercel/analytics": "^1.5.0",
"clsx": "^2.1.1",
@@ -30,8 +36,10 @@
"next": "^15.5.6",
"p5i": "^0.6.0",
"payload": "^3.61.1",
"radix-ui": "^1.4.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-wrap-balancer": "^1",
"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-mono";
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 type { Metadata } from "next";
import { Providers } from "./providers";
+79 -59
View File
@@ -1,68 +1,88 @@
import AppWrapper from "@/components/AppWrapper";
import { getPayload } from "payload";
import config from "../../payload.config";
import { Flex, Button, Text, Container, Box } from "@radix-ui/themes";
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
type Metadata = {
tagline: string;
resume: {
id: string;
url: string;
filename: string;
};
resumeFilename?: string;
};
function SocialLink({
href,
icon: IconComponent,
children,
}: {
href: string;
icon: React.ElementType;
children: React.ReactNode;
}) {
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() {
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 (
<AppWrapper className="overflow-x-hidden" dotsClassName="animate-bg">
<div className="flex h-screen w-screen items-center justify-center overflow-hidden">
<div className="relative z-10 flex w-full flex-col items-center justify-start">
<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}
</Link>
))}
</ul>
</nav>
<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" />
<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">
{title}
</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" />
<div className="max-w-screen-sm animate-fade-in text-center text-sm text-zinc-500 sm:text-base">
<Balancer>{metadata.tagline}</Balancer>
</div>
</div>
</div>
<AppWrapper
className="overflow-x-hidden font-schibsted"
dotsClassName="animate-bg"
>
<Flex
direction="row"
justify="between"
align="center"
width="100%"
pt="5"
px="6"
pb="9"
>
<Flex direction="column">
<Text size="6" weight="bold" highContrast>
Ryan Walters,
</Text>
<Text
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>
);
}
+8 -4
View File
@@ -2,12 +2,16 @@
import { Analytics } from "@vercel/analytics/react";
import { Provider as BalancerProvider } from "react-wrap-balancer";
import { Theme } from "@radix-ui/themes";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<BalancerProvider>
{children}
<Analytics />
</BalancerProvider>
// @ts-expect-error - Radix UI Themes has React 19 type compatibility issues
<Theme appearance="dark">
<BalancerProvider>
{children}
<Analytics />
</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) {
const { CRON_SECRET, GITHUB_API_TOKEN } = process.env;
if (!CRON_SECRET || !GITHUB_API_TOKEN) {
if (!GITHUB_API_TOKEN) {
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 (!CRON_SECRET) {
return NextResponse.json(
{ error: "Server misconfiguration" },
{ status: 500 },
);
}
const authHeader = req.headers.get("authorization");
const url = new URL(req.url);
const secretQueryParam = url.searchParams.get("secret");
+12 -2
View File
@@ -3,10 +3,20 @@ import config from "../../../payload.config";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const secret = req.headers.get("authorization");
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 });
}
+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.
*/
export const serverSchema = z.object({
CRON_SECRET: z.string().nullish(),
GITHUB_API_TOKEN: z.string(),
PAYLOAD_SECRET: z.string(),
DATABASE_URI: z.string(),
CRON_SECRET: z.string().optional(),
GITHUB_API_TOKEN: z.string().optional(),
PAYLOAD_SECRET: z
.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(),
HEALTHCHECK_SECRET: z.string(),
HEALTHCHECK_SECRET: z.string().optional(),
NODE_ENV: z.enum(["development", "test", "production"]),
TITLE: z.preprocess((value) => {
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 };
+3 -2
View File
@@ -13,6 +13,7 @@ import { Projects } from "./collections/Projects";
import { Technologies } from "./collections/Technologies";
import { Links } from "./collections/Links";
import { Metadata } from "./globals/Metadata";
import { env } from "./env/server.mjs";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
@@ -24,13 +25,13 @@ export default buildConfig({
collections: [Users, Media, Projects, Technologies, Links],
globals: [Metadata],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || "",
secret: env.PAYLOAD_SECRET,
typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || "",
connectionString: env.DATABASE_URI,
},
}),
sharp,
+1
View File
@@ -15,6 +15,7 @@
--font-roboto: "Roboto", sans-serif;
--font-mono: "Roboto Mono", monospace;
--font-hanken: "Hanken Grotesk", sans-serif;
--font-schibsted: "Schibsted Grotesk", sans-serif;
/* Background images */
--background-image-gradient-radial: radial-gradient(