mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 08:26:41 -06:00
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:
+10
-12
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+5084
-6291
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
+78
-58
@@ -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}
|
||||
<AppWrapper
|
||||
className="overflow-x-hidden font-schibsted"
|
||||
dotsClassName="animate-bg"
|
||||
>
|
||||
{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>
|
||||
<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 — 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
// @ts-expect-error - Radix UI Themes has React 19 type compatibility issues
|
||||
<Theme appearance="dark">
|
||||
<BalancerProvider>
|
||||
{children}
|
||||
<Analytics />
|
||||
</BalancerProvider>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export const importMap = {};
|
||||
|
||||
|
||||
export const importMap = {
|
||||
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Vendored
+11
-5
@@ -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;
|
||||
|
||||
Vendored
+25
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user