feat: migrate from Next.js Pages Router to App Router with Payload CMS

Complete architectural overhaul migrating from Directus+tRPC to Payload CMS with Next.js App Router. This represents a fundamental shift in how the application is structured and how data is managed.

Major changes:
- Migrated from Pages Router (src/pages/) to App Router (src/app/)
- Replaced Directus CMS with Payload CMS as the content management system
- Removed tRPC in favor of Payload's built-in API routes
- Added PostgreSQL database via Docker Compose for local development
- Implemented separate route groups for frontend and Payload admin
- Updated all API routes to App Router conventions
- Added Payload collections for Projects, Technologies, Links, Media, and Users
- Configured ESLint for new project structure

Infrastructure:
- Added docker-compose.yml for PostgreSQL database
- Updated environment variables for Payload CMS configuration
- Integrated @payloadcms/next for seamless Next.js integration
- Added GraphQL API and playground routes

Dependencies:
- Upgraded React from 18.2.0 to 19.2.0
- Upgraded Next.js to 15.5.6
- Added Payload CMS 3.x packages (@payloadcms/db-postgres, @payloadcms/next, etc.)
- Removed Directus SDK and tRPC packages
- Updated Sharp to 0.34.x
- Migrated to @tanstack/react-query v5
This commit is contained in:
2025-10-26 00:58:10 -05:00
parent 6c043630df
commit 0dcf6f93ba
56 changed files with 6539 additions and 1616 deletions
+17 -4
View File
@@ -6,7 +6,20 @@
# When adding additional env variables, the schema in /env/schema.mjs should be updated accordingly
# Example:
# SERVERVAR=foo
# NEXT_PUBLIC_CLIENTVAR=bar
DIRECTUS_REVALIDATE_KEY=
# Payload CMS
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)
GITHUB_API_TOKEN=
# API Secrets
HEALTHCHECK_SECRET=
CRON_SECRET=
# Optional
TITLE=
# Node environment
NODE_ENV=development
+23
View File
@@ -0,0 +1,23 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: xevion-postgres
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_USER: xevion
POSTGRES_PASSWORD: xevion_dev_password
POSTGRES_DB: xevion_dev
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xevion -d xevion_dev"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
+9 -1
View File
@@ -10,7 +10,15 @@ const compat = new FlatCompat({
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
...compat.extends("next/core-web-vitals"),
{
ignores: [
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;
+2 -1
View File
@@ -1,3 +1,4 @@
import { withPayload } from "@payloadcms/next/withPayload";
// @ts-check
import withPlaiceholder from "@plaiceholder/next";
@@ -91,4 +92,4 @@ const config = {
];
},
};
export default withPlaiceholder(config);
export default withPayload(withPlaiceholder(config));
+24 -20
View File
@@ -2,54 +2,57 @@
"name": "xevion.dev",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "next build",
"dev": "next dev --turbopack",
"lint": "next lint",
"lint": "eslint .",
"start": "next start"
},
"dependencies": {
"@directus/sdk": "^18.0.3",
"@floating-ui/react": "^0.27.16",
"@headlessui/react": "^2.2.0",
"@kodingdotninja/use-tailwind-breakpoint": "^1.0.0",
"@next/eslint-plugin-next": "^15.1.1",
"@octokit/core": "^6.1.2",
"@payloadcms/db-postgres": "^3.61.1",
"@payloadcms/next": "^3.61.1",
"@payloadcms/payload-cloud": "^3.61.1",
"@payloadcms/richtext-lexical": "^3.61.1",
"@plaiceholder/next": "^3.0.0",
"@tailwindcss/typography": "^0.5.8",
"@tanstack/react-query": "^4.16.0",
"@tippyjs/react": "^4.2.6",
"@trpc/client": "^10.0.0",
"@trpc/next": "^10.0.0",
"@trpc/react-query": "^10.0.0",
"@trpc/server": "^10.0.0",
"@vercel/analytics": "^0.1.6",
"@tanstack/react-query": "^5.90",
"@vercel/analytics": "^1.5.0",
"clsx": "^2.1.1",
"cssnano": "^7.0.6",
"next": "^15.1.1",
"graphql": "^16.11.0",
"next": "^15.5.6",
"p5i": "^0.6.0",
"payload": "^3.61.1",
"plaiceholder": "^3.0.0",
"prettier": "^3.4.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-icons": "^4.10.1",
"react-markdown": "^9.0.1",
"react-wrap-balancer": "^0.4.0",
"react-wrap-balancer": "^1",
"sass": "^1.56.2",
"sharp": "^0.32.1",
"superjson": "1.9.1",
"sharp": "^0.34",
"superjson": "^2.2",
"tailwind-merge": "^2.6.0",
"usehooks-ts": "^3.1.0",
"usehooks-ts": "^3.1.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.38.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.7",
"eslint": "^9",
"eslint-config-next": "15.1.1",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.1",
"postcss": "^8",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.1",
@@ -57,5 +60,6 @@
},
"ct3aMetadata": {
"initVersion": "6.11.3"
}
},
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf"
}
+4704 -610
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
"use client";
import AppWrapper from "@/components/AppWrapper";
import { BsDiscord, BsGithub } from "react-icons/bs";
import { AiFillMail } from "react-icons/ai";
import Link from "next/link";
import type { IconType } from "react-icons";
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
} from "@floating-ui/react";
import { useState } from "react";
const socials: {
icon: IconType;
href?: string;
hint?: string;
hideHint?: boolean;
}[] = [
{
icon: BsGithub,
href: "https://github.com/Xevion/",
},
{
icon: AiFillMail,
href: "mailto:xevion@xevion.dev",
hint: "xevion@xevion.dev",
},
{
icon: BsDiscord,
hint: "Xevion#8506",
},
];
function SocialTooltip({
icon: Icon,
href,
hint,
hideHint,
}: {
icon: IconType;
href?: string;
hint?: string;
hideHint?: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: "top",
whileElementsMounted: autoUpdate,
middleware: [offset(10), flip(), shift()],
});
const hover = useHover(context);
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: "tooltip" });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
const inner = <Icon className="h-8 w-8" />;
const tooltipContent = hint ?? href;
return (
<>
{href != undefined ? (
<Link
href={href}
ref={refs.setReference}
{...getReferenceProps()}
>
{inner}
</Link>
) : (
<span
ref={refs.setReference}
{...getReferenceProps()}
>
{inner}
</span>
)}
{!hideHint && isOpen && tooltipContent && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className="z-50 rounded bg-zinc-900 px-3 py-2 text-sm text-zinc-100 shadow-lg"
>
{tooltipContent}
</div>
</FloatingPortal>
)}
</>
);
}
export default function ContactPage() {
return (
<AppWrapper>
<div className="my-10 flex w-full flex-col items-center">
<div className="mx-3 flex w-full max-w-[23rem] flex-col rounded-md border border-zinc-800 bg-zinc-800/50 p-5 sm:max-w-[25rem] lg:max-w-[30rem]">
<div className="flex justify-center gap-x-5 text-center">
{socials.map((social, index) => (
<SocialTooltip key={index} {...social} />
))}
</div>
</div>
</div>
</AppWrapper>
);
}
+25
View File
@@ -0,0 +1,25 @@
import React from "react";
import "@/styles/globals.scss";
import type { Metadata } from "next";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "Xevion.dev",
description:
"The personal website of Xevion, a full-stack software developer.",
applicationName: "xevion.dev",
};
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props;
return (
<html lang="en">
<body>
<Providers>
<main>{children}</main>
</Providers>
</body>
</html>
);
}
+68
View File
@@ -0,0 +1,68 @@
import AppWrapper from "@/components/AppWrapper";
import { getPayload } from "payload";
import config from "../../payload.config";
import Link from "next/link";
import Balancer from "react-wrap-balancer";
export const dynamic = "force-dynamic"; // Don't prerender at build time
type Metadata = {
tagline: string;
resume: {
id: string;
url: string;
filename: string;
};
resumeFilename?: string;
};
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>
);
}
+95
View File
@@ -0,0 +1,95 @@
import AppWrapper from "@/components/AppWrapper";
import { cn } from "@/utils/helpers";
import Link from "next/link";
import Balancer from "react-wrap-balancer";
import { getPayload } from "payload";
import config from "../../../payload.config";
import type { Link as PayloadLink } from "@/payload-types";
export const dynamic = "force-dynamic"; // Don't prerender at build time
export default async function ProjectsPage() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
// Fetch all projects
const { docs: projects } = await payload.find({
collection: "projects",
where: {
status: {
equals: "published",
},
},
sort: "-updatedAt",
});
// Fetch all links in one query (fixes N+1 problem)
const { docs: allLinks } = await payload.find({
collection: "links",
});
// Group links by project ID
const linksByProject = new Map<number, PayloadLink[]>();
for (const link of allLinks) {
const projectId = typeof link.project === "number" ? link.project : link.project.id;
if (!linksByProject.has(projectId)) {
linksByProject.set(projectId, []);
}
linksByProject.get(projectId)!.push(link);
}
return (
<AppWrapper dotsClassName="animate-bg-fast">
<div className="relative z-10 mx-auto grid grid-cols-1 justify-center gap-y-4 px-4 py-20 align-middle sm:grid-cols-2 md:max-w-[50rem] lg:max-w-[75rem] lg:grid-cols-3 lg:gap-y-9">
<div className="mb-3 text-center sm:col-span-2 md:mb-5 lg:col-span-3 lg:mb-7">
<h1 className="pb-3 font-hanken text-4xl text-zinc-200 opacity-100 md:text-5xl">
Projects
</h1>
<Balancer className="text-lg text-zinc-400">
created, maintained, or contributed to by me...
</Balancer>
</div>
{projects.map(({ id, name, shortDescription: description, icon }) => {
const links = linksByProject.get(id) ?? [];
const useAnchor = links.length > 0;
const DynamicLink = useAnchor ? Link : "div";
const linkProps = useAnchor
? { href: links[0]!.url, target: "_blank", rel: "noreferrer" }
: {};
return (
<div className="max-w-fit" key={id}>
{/* @ts-expect-error because div can't accept href */}
<DynamicLink
key={name}
title={name}
className="flex items-center justify-start overflow-hidden rounded bg-black/10 pb-2.5 pl-3 pr-5 pt-1 text-zinc-400 transition-colors hover:bg-zinc-500/10 hover:text-zinc-50"
{...linkProps}
>
<div className="flex h-full w-14 items-center justify-center pr-5">
<i
className={cn(
icon ?? "fa-heart",
"fa-solid text-3xl text-opacity-80 saturate-0",
)}
></i>
</div>
<div className="overflow-hidden">
<span className="text-sm md:text-base lg:text-lg">
{name}
</span>
<p
className="truncate text-xs opacity-70 md:text-sm lg:text-base"
title={description}
>
{description}
</p>
</div>
</DynamicLink>
</div>
);
})}
</div>
</AppWrapper>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { Analytics } from "@vercel/analytics/react";
import { Provider as BalancerProvider } from "react-wrap-balancer";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<BalancerProvider>
{children}
<Analytics />
</BalancerProvider>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { getPayload } from "payload";
import config from "../../../payload.config";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic"; // Don't prerender at build time
type Metadata = {
tagline: string;
resume: {
id: string;
url: string;
filename: string;
};
resumeFilename?: string;
};
export default async function ResumePage() {
try {
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;
if (!metadata.resume?.url) {
throw new Error("Resume URL not found");
}
redirect(metadata.resume.url);
} catch (error) {
console.error("Failed to acquire resume asset URL", error);
throw new Error(`Failed to acquire resume (${error})`);
}
}
+164
View File
@@ -0,0 +1,164 @@
:root {
--font-mono: "Roboto Mono", monospace;
}
* {
box-sizing: border-box;
}
html {
font-size: 18px;
line-height: 32px;
background: rgb(0, 0, 0);
-webkit-font-smoothing: antialiased;
}
html,
body,
#app {
height: 100%;
}
body {
font-family: system-ui;
font-size: 18px;
line-height: 32px;
margin: 0;
color: rgb(1000, 1000, 1000);
@media (max-width: 1024px) {
font-size: 15px;
line-height: 24px;
}
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1 {
margin: 40px 0;
font-size: 64px;
line-height: 70px;
font-weight: bold;
@media (max-width: 1024px) {
margin: 24px 0;
font-size: 42px;
line-height: 42px;
}
@media (max-width: 768px) {
font-size: 38px;
line-height: 38px;
}
@media (max-width: 400px) {
font-size: 32px;
line-height: 32px;
}
}
p {
margin: 24px 0;
@media (max-width: 1024px) {
margin: calc(var(--base) * 0.75) 0;
}
}
a {
color: currentColor;
&:focus {
opacity: 0.8;
outline: none;
}
&:active {
opacity: 0.7;
outline: none;
}
}
svg {
vertical-align: middle;
}
.home {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
height: 100vh;
padding: 45px;
max-width: 1024px;
margin: 0 auto;
overflow: hidden;
@media (max-width: 400px) {
padding: 24px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
h1 {
text-align: center;
}
}
.links {
display: flex;
align-items: center;
gap: 12px;
a {
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.admin {
color: rgb(0, 0, 0);
background: rgb(1000, 1000, 1000);
border: 1px solid rgb(0, 0, 0);
}
.docs {
color: rgb(1000, 1000, 1000);
background: rgb(0, 0, 0);
border: 1px solid rgb(1000, 1000, 1000);
}
}
.footer {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 1024px) {
flex-direction: column;
gap: 6px;
}
p {
margin: 0;
}
.codeLink {
text-decoration: none;
padding: 0 0.5rem;
background: rgb(60, 60, 60);
border-radius: 4px;
}
}
}
@@ -0,0 +1,27 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
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<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
export const generateMetadata = ({
params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap });
export default NotFound;
@@ -0,0 +1,27 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
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<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
export const generateMetadata = ({
params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap });
export default Page;
+1
View File
@@ -0,0 +1 @@
export const importMap = {};
+19
View File
@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "../../../../payload.config";
import "@payloadcms/next/css";
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);
@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "../../../../payload.config";
import "@payloadcms/next/css";
import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
export const GET = GRAPHQL_PLAYGROUND_GET(config);
+8
View File
@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
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);
View File
+35
View File
@@ -0,0 +1,35 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from "../../payload.config";
import "@payloadcms/next/css";
import type { ServerFunctionClient } from "payload";
import { handleServerFunctions, RootLayout } from "@payloadcms/next/layouts";
import React from "react";
import { importMap } from "./admin/importMap.js";
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;
@@ -1,23 +1,7 @@
/**
* This is a cron job handler for acquiring the latest 'updated' data for the site's projects.
*
* 1) Fetch the list of all projects including their link URLs.
* 2) Filter the list only for projects with 'autocheck_update' enabled and any 'github.com' link.
* 3) For each project, query the GitHub API for the latest commit date on all branches.
* 4) If the latest commit date is newer than the project's 'last_updated' date, update the project's 'last_updated' date.
* 5) If any project's 'last_updated' date was updated, revalidate the index and/or project page.
* 6) Report the results of this cron job invocation.
*
* - This cron job runs at least once a day, at most once an hour.
* - This cron job is completely asynchronous but respects GitHub API rate limits.
* - This cron job requires authentication with the Directus API.
* - This cron job requires authentication with the GitHub API (mostly for rate limits).
*/
import directus, { ProjectLink } from "@/utils/directus";
import { readItems, updateItem } from "@directus/sdk";
import { NextApiRequest, NextApiResponse } from "next";
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "../../../../payload.config";
import { Octokit } from "@octokit/core";
import { isFulfilled, isRejected } from "@/utils/types";
const octokit = new Octokit({
auth: process.env.GITHUB_API_TOKEN,
@@ -30,29 +14,40 @@ const octokit = new Octokit({
});
type ProjectResult = {
id: string;
id: number;
previousUpdated: Date | null;
latestUpdated: Date | null;
};
function getRepository(url: string): [string, string] | null {
const pattern = /github.com\/([^/]+)\/([^/]+)/;
const match = pattern.exec(url);
const pattern = /github\.com\/([^/]+)\/([^/]+)/;
const match = url.match(pattern);
if (match === null) return null;
return [match[1]!, match[2]!];
}
function isFulfilled<T>(
result: PromiseSettledResult<T>,
): result is PromiseFulfilledResult<T> {
return result.status === "fulfilled";
}
function isRejected<T>(
result: PromiseSettledResult<T>,
): result is PromiseRejectedResult {
return result.status === "rejected";
}
async function handleProject({
id: project_id,
urls,
date_updated: previousUpdated,
}: {
id: string;
id: number;
urls: string[];
date_updated: Date | null;
}): Promise<ProjectResult> {
// Extract the branches from each URL
const allBranches = await Promise.all(
urls.map(async (url) => {
const details = getRepository(url);
@@ -60,7 +55,6 @@ async function handleProject({
return [];
}
// TODO: Handle deduplication of repository targets
const [owner, repo] = details;
const branches = await octokit.request(
"GET /repos/{owner}/{repo}/branches",
@@ -81,7 +75,6 @@ async function handleProject({
}),
);
// Get the latest commit date for each branch (flattened)
const latestCommits = allBranches
.flat()
.map(async ({ owner, repo, branch }) => {
@@ -99,7 +92,6 @@ async function handleProject({
);
const latestCommit = commits.data[0];
// Commits not returned
if (latestCommit == null) {
console.warn({
target: `${owner}/${repo}@${branch}`,
@@ -108,7 +100,6 @@ async function handleProject({
return null;
}
// Handle missing commit data in unpredictable cases
if (latestCommit.commit.author == null) {
console.warn({
target: `${owner}/${repo}@${branch}`,
@@ -134,13 +125,10 @@ async function handleProject({
const results = await Promise.allSettled(latestCommits);
// Handle the promises that failed
results.filter(isRejected).forEach((result) => {
// TODO: Add more context to the error message
console.error("Failed to fetch latest commit date", result.reason);
});
// Find the latest commit date
const latestUpdated = results
.filter(isFulfilled)
.map((v) => v.value)
@@ -159,7 +147,6 @@ async function handleProject({
};
}
// Ensure it's a reasonable date
if (latestUpdated != null && latestUpdated < new Date("2015-01-01")) {
console.error("Invalid commit date acquired", latestUpdated);
return {
@@ -171,15 +158,18 @@ async function handleProject({
const result = { id: project_id, previousUpdated, latestUpdated: null };
// Update the project's 'last_updated' date if the latest commit date is newer
if (previousUpdated == null || latestUpdated > previousUpdated) {
await directus.request(
updateItem("project", project_id, {
date_updated: latestUpdated,
}),
);
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
await payload.update({
collection: "projects",
id: project_id,
data: {
lastUpdated: latestUpdated.toISOString(),
},
});
// 'latestUpdated' is not null ONLY if the project was actually updated
return {
...result,
latestUpdated,
@@ -189,26 +179,24 @@ async function handleProject({
return result;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
// Check for the required environment variables
const { CRON_SECRET, GITHUB_API_TOKEN, DIRECTUS_API_TOKEN } = process.env;
if (!CRON_SECRET || !GITHUB_API_TOKEN || !DIRECTUS_API_TOKEN) {
res.status(500).json({ error: "Missing environment variables" });
export async function GET(req: Request) {
const { CRON_SECRET, GITHUB_API_TOKEN } = process.env;
if (!CRON_SECRET || !GITHUB_API_TOKEN) {
return NextResponse.json(
{ error: "Missing environment variables" },
{ status: 500 },
);
}
// Ensure the cron request is authenticated
if (process.env.NODE_ENV === "production") {
const authHeader = req.headers["authorization"];
const secretQueryParam = req.query.secret;
const authHeader = req.headers.get("authorization");
const url = new URL(req.url);
const secretQueryParam = url.searchParams.get("secret");
if (
authHeader !== `Bearer ${CRON_SECRET}` &&
secretQueryParam !== CRON_SECRET
) {
res.status(401).json({ error: "Unauthorized" });
return;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
@@ -218,67 +206,61 @@ export default async function handler(
});
try {
// Fetch the list of all projects including their link URLs.
const projects = await directus.request(
readItems("project", {
fields: [
"id",
"name",
"autocheckUpdated",
"date_updated",
{ links: ["url"] },
],
}),
);
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { docs: projects } = await payload.find({
collection: "projects",
});
const { docs: allLinks } = await payload.find({
collection: "links",
});
// Filter the list only for projects with 'autocheck_update' enabled and any 'github.com' link.
const eligibleProjects = projects
.map((project) => {
// Skip projects that don't have autocheckUpdated enabled.
if (!project.autocheckUpdated) return null;
// Acquire the URL from the link, then filter out any non-GitHub URLs.
const urls = project
.links!.map((link) => {
return (<ProjectLink>link).url;
const urls = allLinks
.filter((link) => {
const projectId =
typeof link.project === "number"
? link.project
: link.project.id;
return projectId === project.id;
})
.map((link) => link.url)
.filter((url) => url.includes("github.com"));
// Skip projects that don't have any GitHub URLs.
if (urls.length === 0) return null;
// Return the project's most important data for further processing.
return {
id: project.id,
name: project.name,
date_updated: project.date_updated,
date_updated:
project.lastUpdated != null ? new Date(project.lastUpdated) : null,
urls,
};
})
// null values are still included in the array, so filter them out.
.filter((project) => project !== null);
// For each project, query the GitHub API for the latest commit date on all branches.
const projectPromises = eligibleProjects.map((project) =>
handleProject({
id: project.id,
urls: project.urls,
date_updated:
project.date_updated != null ? new Date(project.date_updated) : null,
date_updated: project.date_updated,
}),
);
// Wait for all project promises to resolve
const results = await Promise.allSettled(projectPromises);
// If more than 10% of the requests failed, return an error status code
const isFailed = results.filter(isRejected).length > results.length * 0.1;
type Response = {
request_count: number;
errors: { project_name: string; reason: string }[];
ignored: string[];
changed: { project_name: string; previous: Date | null; latest: Date }[];
ignored: number[];
changed: { project_name: number; previous: Date | null; latest: Date }[];
};
const fulfilled = results.filter(isFulfilled);
@@ -286,7 +268,6 @@ export default async function handler(
const response: Response = {
request_count,
errors: results.filter(isRejected).map((r) => ({
// TODO: Fix this project name
project_name: "unknown",
reason: r.reason,
})),
@@ -302,9 +283,8 @@ export default async function handler(
})),
};
res.status(!isFailed ? 200 : 500).json(response);
return NextResponse.json(response, { status: !isFailed ? 200 : 500 });
} catch (error) {
res.status(500).json({ error });
return;
return NextResponse.json({ error }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { getPayload } from "payload";
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) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
// Try a simple Payload API call (fetch one project)
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
await payload.find({
collection: "projects",
limit: 1,
});
return NextResponse.json({ status: "ok" }, { status: 200 });
} catch (error) {
return NextResponse.json(
{ error: "Payload CMS unhealthy", details: String(error) },
{ status: 500 },
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const requestSchema = z.object({
collection: z.string(),
doc: z.object({
id: z.string().or(z.number()),
}),
});
function getPathsToRevalidate(collection: string): string[] {
switch (collection) {
case "projects":
return ["/projects"];
case "metadata":
return ["/"];
case "technologies":
return ["/projects"];
case "links":
return ["/projects"];
default:
return [];
}
}
export async function POST(req: Request) {
const revalidateKey = process.env.PAYLOAD_REVALIDATE_KEY;
const authHeader = req.headers.get("authorization");
if (!authHeader || authHeader !== `Bearer ${revalidateKey}`) {
return NextResponse.json({ message: "Invalid token" }, { status: 401 });
}
try {
const body = await req.json();
const { success, data, error } = requestSchema.safeParse(body);
if (!success) {
console.error({ message: "Invalid JSON body", error });
return NextResponse.json(
{ message: "Invalid JSON body", error },
{ status: 400 },
);
}
const paths = getPathsToRevalidate(data.collection);
if (paths.length === 0) {
return NextResponse.json(
{ revalidated: false, message: "No paths to revalidate" },
{ status: 404 },
);
}
// Revalidate all paths
try {
for (const path of paths) {
revalidatePath(path);
}
} catch (error) {
console.error({ message: "Error while revalidating", error });
return NextResponse.json(
{
revalidated: false,
message: "Error while revalidating",
paths,
},
{ status: 500 },
);
}
return NextResponse.json({ revalidated: true, paths }, { status: 200 });
} catch (error) {
console.error({
message: "Error while preparing to revalidate",
error,
});
return NextResponse.json(
{ message: "Error revalidating" },
{ status: 500 },
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { CollectionConfig } from "payload";
export const Links: CollectionConfig = {
slug: "links",
admin: {
useAsTitle: "url",
},
fields: [
{
name: "url",
type: "text",
required: true,
label: "URL",
},
{
name: "icon",
type: "text",
label: "Icon (FontAwesome class)",
},
{
name: "description",
type: "text",
},
{
name: "project",
type: "relationship",
relationTo: "projects",
required: true,
},
],
};
+16
View File
@@ -0,0 +1,16 @@
import type { CollectionConfig } from "payload";
export const Media: CollectionConfig = {
slug: "media",
access: {
read: () => true,
},
fields: [
{
name: "alt",
type: "text",
required: true,
},
],
upload: true,
};
+92
View File
@@ -0,0 +1,92 @@
import type { CollectionConfig } from "payload";
export const Projects: CollectionConfig = {
slug: "projects",
admin: {
useAsTitle: "name",
defaultColumns: ["name", "featured", "status", "updatedAt"],
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "description",
type: "textarea",
required: true,
},
{
name: "shortDescription",
type: "text",
label: "Short Description",
required: true,
},
{
name: "icon",
type: "text",
label: "Icon (FontAwesome class)",
},
{
name: "status",
type: "select",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
{ label: "Archived", value: "archived" },
],
defaultValue: "draft",
required: true,
},
{
name: "featured",
type: "checkbox",
label: "Featured Project",
defaultValue: false,
},
{
name: "autocheckUpdated",
type: "checkbox",
label: "Auto-check for GitHub updates",
defaultValue: false,
admin: {
description:
"Automatically check GitHub for latest commits and update lastUpdated field",
},
},
{
name: "lastUpdated",
type: "date",
label: "Last Updated",
admin: {
description:
"Automatically updated by cron job based on GitHub commits",
date: {
displayFormat: "yyyy-MM-dd HH:mm:ss",
},
},
},
{
name: "wakatimeOffset",
type: "number",
label: "WakaTime Offset",
admin: {
description: "Offset for WakaTime fetched data (optional)",
},
},
{
name: "bannerImage",
type: "upload",
relationTo: "media",
label: "Banner Image",
},
{
name: "technologies",
type: "relationship",
relationTo: "technologies",
hasMany: true,
label: "Technologies Used",
},
],
};
+20
View File
@@ -0,0 +1,20 @@
import type { CollectionConfig } from "payload";
export const Technologies: CollectionConfig = {
slug: "technologies",
admin: {
useAsTitle: "name",
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "url",
type: "text",
label: "URL",
},
],
};
+13
View File
@@ -0,0 +1,13 @@
import type { CollectionConfig } from "payload";
export const Users: CollectionConfig = {
slug: "users",
admin: {
useAsTitle: "email",
},
auth: true,
fields: [
// Email added by default
// Add more fields as needed
],
};
+4 -8
View File
@@ -1,3 +1,5 @@
"use client";
import { cn } from "@/utils/helpers";
import dynamic from "next/dynamic";
import type { FunctionComponent, ReactNode } from "react";
@@ -8,10 +10,7 @@ type WrapperProps = {
children?: ReactNode;
};
const DotsDynamic = dynamic(
() => import('@/components/Dots'),
{ ssr: false }
)
const DotsDynamic = dynamic(() => import("@/components/Dots"), { ssr: false });
const AppWrapper: FunctionComponent<WrapperProps> = ({
children,
@@ -20,10 +19,7 @@ const AppWrapper: FunctionComponent<WrapperProps> = ({
}: WrapperProps) => {
return (
<main
className={cn(
"min-h-screen text-zinc-50",
className,
)}
className={cn("relative min-h-screen bg-black text-zinc-50", className)}
>
<DotsDynamic className={dotsClassName} />
{children}
+112 -96
View File
@@ -1,119 +1,135 @@
import { cn } from '@/utils/helpers';
import { p5i, P5I } from 'p5i';
import React, { useEffect, useRef } from 'react';
import { cn } from "@/utils/helpers";
import { p5i, P5I } from "p5i";
import React, { useEffect, useRef } from "react";
interface DotsProps {
className?: string;
className?: string;
}
const Dots = ({
className,
}: DotsProps) => {
const canvasRef = useRef<HTMLDivElement | null>(null);
const Dots = ({ className }: DotsProps) => {
const canvasRef = useRef<HTMLDivElement | null>(null);
const w = useRef(window.innerWidth);
const h = useRef(window.innerHeight);
const {
mount,
unmount,
background,
stroke,
noise,
resizeCanvas,
cos,
sin,
TWO_PI,
} = p5i();
const {
mount,
unmount,
background,
stroke,
noise,
resizeCanvas,
cos,
sin,
TWO_PI,
} = p5i();
let w = window.innerWidth;
let h = window.innerHeight;
const offsetY = window.scrollY;
const offsetY = window.scrollY;
const SCALE = 400;
const LENGTH = 3;
const SPACING = 20;
const TARGET_FRAMERATE = 5;
const TIMESCALE = 2;
const OPACITY = 0.7;
const SCALE = 400;
const LENGTH = 3;
const SPACING = 20;
const TARGET_FRAMERATE = 5;
const TIMESCALE = 2;
const OPACITY = 0.7;
function getForceOnPoint(x: number, y: number, z: number) {
return (noise(x / SCALE, y / SCALE, z) - 0.5) * 2 * TWO_PI;
function getForceOnPoint(x: number, y: number, z: number) {
return (noise(x / SCALE, y / SCALE, z) - 0.5) * 2 * TWO_PI;
}
const pointIds = new Set<string>();
const points: { x: number; y: number; opacity: number }[] = [];
function addPoints() {
for (let x = -SPACING / 2; x < w.current + SPACING; x += SPACING) {
for (let y = -SPACING / 2; y < h.current + offsetY + SPACING; y += SPACING) {
const id = `${x}-${y}`;
if (pointIds.has(id)) continue;
pointIds.add(id);
points.push({ x, y, opacity: Math.random() * 0.5 + 0.5 });
}
}
}
const pointIds = new Set<string>();
const points: { x: number, y: number, opacity: number }[] = [];
function setup({
createCanvas,
stroke,
frameRate,
background,
noFill,
noiseSeed,
}: P5I) {
createCanvas(w.current, h.current);
background("#000000");
stroke("rgba(170, 170, 170, 0.05)");
noFill();
function addPoints() {
for (let x = -SPACING / 2; x < w + SPACING; x += SPACING) {
for (let y = -SPACING / 2; y < h + offsetY + SPACING; y += SPACING) {
const id = `${x}-${y}`;
if (pointIds.has(id)) continue;
pointIds.add(id);
frameRate(TARGET_FRAMERATE);
noiseSeed(Date.now());
points.push({ x, y, opacity: Math.random() * 0.5 + 0.5 });
}
}
addPoints();
}
function draw({ circle, frameCount }: P5I) {
background("#000000");
const t = (frameCount / 80) * TIMESCALE;
// if (frameCount % 10000) console.log(frameRate());
for (const p of points) {
const rad = getForceOnPoint(p.x, p.y, t);
const length = (noise(p.x / SCALE, p.y / SCALE, t * 2) + 0.5) * LENGTH;
const nx = p.x + cos(rad) * length;
const ny = p.y + sin(rad) * length;
// const center_distance = Math.sqrt((x - w / 2) ** 2 + (y - h / 2) ** 2);
// if (center_distance < 350)
// opacity = 0;
// opacity =
stroke(
200,
200,
200,
(Math.abs(cos(rad)) * 0.8 + 0.1) * p.opacity * 255 * OPACITY,
);
circle(nx, ny - offsetY, 1);
}
}
function setup({ createCanvas, stroke, frameRate, background, noFill, noiseSeed }: P5I) {
createCanvas(w, h);
background('#000000');
stroke('rgba(170, 170, 170, 0.05)');
noFill();
frameRate(TARGET_FRAMERATE);
noiseSeed(Date.now());
addPoints();
function restart() {
if (canvasRef.current) {
mount(canvasRef.current, { setup, draw });
}
}
function draw({ circle, frameCount }: P5I) {
background('#000000');
const t = (frameCount / 80) * TIMESCALE;
useEffect(() => {
restart();
// if (frameCount % 10000) console.log(frameRate());
const handleResize = () => {
w.current = window.innerWidth;
h.current = window.innerHeight;
resizeCanvas(w.current, h.current);
addPoints();
};
for (const p of points) {
const rad = getForceOnPoint(p.x, p.y, t);
const length = (noise(p.x / SCALE, p.y / SCALE, t * 2) + 0.5) * LENGTH;
const nx = p.x + cos(rad) * length;
const ny = p.y + sin(rad) * length;
window.addEventListener("resize", handleResize);
// const center_distance = Math.sqrt((x - w / 2) ** 2 + (y - h / 2) ** 2);
// if (center_distance < 350)
// opacity = 0;
// opacity =
stroke(200, 200, 200, (Math.abs(cos(rad)) * 0.8 + 0.1) * p.opacity * 255 * OPACITY);
circle(nx, ny - offsetY, 1);
}
}
return () => {
window.removeEventListener("resize", handleResize);
unmount();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function restart() {
if (canvasRef.current) {
mount(canvasRef.current, { setup, draw });
}
}
useEffect(() => {
restart();
const handleResize = () => {
w = window.innerWidth;
h = window.innerHeight;
resizeCanvas(w, h);
addPoints();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
unmount();
};
}, []);
return <div ref={canvasRef} className={cn(
"fixed left-0 right-0 top-0 bottom-0 pointer-events-none -z-10 opacity-0",
className
)} />;
return (
<div
ref={canvasRef}
className={cn(
"pointer-events-none fixed bottom-0 left-0 right-0 top-0 z-0 opacity-0",
className,
)}
/>
);
};
export default Dots;
+1 -3
View File
@@ -62,9 +62,7 @@ const ItemCard = ({
fill
src={banner}
quality={bannerSettings?.quality ?? 75}
className={(loaded) =>
cn("object-cover", loaded ? null : "blur-xl")
}
className={(loaded) => cn("object-cover", loaded ? null : "blur-xl")}
alt={`Banner for ${title}`}
/>
<div className="elements m-2 grid h-full grid-cols-12 px-1 sm:px-4">
+4 -3
View File
@@ -8,9 +8,10 @@ import { z } from "zod";
export const serverSchema = z.object({
CRON_SECRET: z.string().nullish(),
GITHUB_API_TOKEN: z.string(),
DIRECTUS_API_TOKEN: z.string(),
DIRECTUS_REVALIDATE_KEY: z.string(),
HEALTHCHECK_SECRET: z.string(), // Added for healthcheck route
PAYLOAD_SECRET: z.string(),
DATABASE_URI: z.string(),
PAYLOAD_REVALIDATE_KEY: z.string().optional(),
HEALTHCHECK_SECRET: z.string(),
NODE_ENV: z.enum(["development", "test", "production"]),
TITLE: z.preprocess((value) => {
if (value === undefined || value === "") return null;
+32
View File
@@ -0,0 +1,32 @@
import type { GlobalConfig } from "payload";
export const Metadata: GlobalConfig = {
slug: "metadata",
access: {
read: () => true,
},
fields: [
{
name: "tagline",
type: "textarea",
required: true,
label: "Site Tagline",
},
{
name: "resume",
type: "upload",
relationTo: "media",
required: true,
label: "Resume File",
},
{
name: "resumeFilename",
type: "text",
label: "Resume Filename Override",
admin: {
description:
'Optional: Override the filename for the resume (e.g., "resume.pdf")',
},
},
],
};
-43
View File
@@ -1,43 +0,0 @@
import directus from "@/utils/directus";
import { readSingleton } from "@directus/sdk";
import {
GetStaticPaths,
GetStaticPropsContext,
GetStaticPropsResult,
} from "next";
// 'blocking' fallback, but don't provide any paths for pre-rendering; it will fail otherwise for redirect paths.
export const getStaticPaths: GetStaticPaths = async () => {
return { paths: [], fallback: "blocking" };
};
// Handle static props for `[resume]` route.
export async function getStaticProps({
params,
}: GetStaticPropsContext<{ resume: string }>): Promise<
GetStaticPropsResult<never>
> {
const { resume } = params ?? {};
if (resume !== "resume") return { notFound: true };
try {
console.log("Revalidating resume redirect");
const metadata = await directus.request(readSingleton("metadata"));
const resumeUrl = `${directus.url}assets/${metadata.resume}/${
metadata.resumeFilename ?? "resume.pdf"
}`;
return {
redirect: { destination: resumeUrl, permanent: false },
revalidate: 3600,
};
} catch (error) {
console.error("Failed to acquire resume asset URL", error);
throw new Error(`Failed to acquire asset (${error})`);
}
}
// Empty component as the page redirects or returns `notFound`.
export default function Resume() {
return <></>;
}
-27
View File
@@ -1,27 +0,0 @@
import { type AppType } from "next/app";
import { trpc } from "@/utils/trpc";
import "@/styles/globals.scss";
import { Analytics } from "@vercel/analytics/react";
import { Provider } from "react-wrap-balancer";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Xevion.dev",
description: "The personal website of Xevion, a full-stack software developer.",
applicationName: "xevion.dev",
}
const MyApp: AppType = ({ Component, pageProps }) => {
return (
<>
<Provider>
<Component {...pageProps} />
</Provider>
<Analytics />
</>
);
};
export default trpc.withTRPC(MyApp);
-34
View File
@@ -1,34 +0,0 @@
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html>
<Head>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossOrigin="anonymous" referrerPolicy="no-referrer" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#000000" />
</Head>
<body className="bg-black">
<Main />
<NextScript />
</body>
</Html>
);
}
-24
View File
@@ -1,24 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import directus from "@/utils/directus";
import { env } from "@/env/server.mjs";
import { readItems } from "@directus/sdk";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const secret = req.headers["authorization"];
if (typeof secret !== "string" || secret !== env.HEALTHCHECK_SECRET) {
return res.status(401).json({ error: "Unauthorized" });
}
try {
// Try a simple Directus API call (fetch one project)
await directus.request(readItems("project", { limit: 1 }));
return res.status(200).json({ status: "ok" });
} catch (error) {
return res
.status(500)
.json({ error: "Directus unhealthy", details: String(error) });
}
}
-108
View File
@@ -1,108 +0,0 @@
import { env } from "@/env/server.mjs";
import directus from "@/utils/directus";
import { readItem } from "@directus/sdk";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
async function getURLs(
type: string,
key: string | number,
payload: Record<string, unknown>,
): Promise<string[] | null> {
if (type == "project_link" || type == "project_technology") {
console.error({
message: `Failed to provide URls for '${type}' type`,
type,
key,
payload,
});
return [];
}
if (type === "project") return ["/projects"];
if (type === "metadata") return ["/"];
if (type === "technology") {
const urls = ["/technology"];
// Get all projects with the technology
// const all_projects = await directus.request(readItems("project", {
// fields: ["id", {
// technologies: ["id"],
// }],
// }));
// if (all_projects != null) {
// for (const project of all_projects) {
// if (project.technologies?.some((t) => t.id === key))
// urls.push(`/projects/${project.id}`);
// }
// }
return urls;
}
if (type === "projects") {
const urls = ["/projects", `/projects/${key}`];
// TODO: If 'featured', index page should be revalidated
const project = await directus.request(readItem("project", key));
if (project != null) return urls;
}
return null;
}
const requestSchema = z.object({
type: z.string(),
keys: z.array(z.string().or(z.number().int())).min(1),
source: z.record(z.string(), z.any()),
payload: z.record(z.string(), z.any()),
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST")
return res.status(405).json({ message: "Method not allowed" });
if (req.headers["authorization"] !== `Bearer ${env.DIRECTUS_REVALIDATE_KEY}`)
return res.status(401).json({ message: "Invalid token" });
try {
// Verify JSON body
const { success, data, error } = requestSchema.safeParse(req.body);
if (!success) {
console.error({ message: "Invalid JSON body", error });
return res.status(400).json({ message: "Invalid JSON body", error });
}
// Get URLs
const urls = await getURLs(data.type, data.keys[0]!, data.payload);
if (urls === null)
return res
.status(404)
.json({ revalidated: false, message: "Collection not found" });
// Revalidate all URLs
try {
await Promise.all(urls.map((url) => res.revalidate(url)));
} catch (error) {
console.error({ message: "Error while revalidating", error });
return res.status(500).json({
revalidated: false,
message: "Error while revalidating",
urls,
});
}
// Return success
return res.json({ revalidated: true, urls });
} catch (error) {
console.error({
message: "Error while preparing to revalidate",
error,
});
return res.status(500).send("Error revalidating");
}
}
-17
View File
@@ -1,17 +0,0 @@
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { env } from "@/env/server.mjs";
import { createContext } from "@/server/trpc/context";
import { appRouter } from "@/server/trpc/router/_app";
// export API handler
export default createNextApiHandler({
router: appRouter,
createContext,
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(`❌ tRPC failed on ${path}: ${error}`);
}
: undefined,
});
-56
View File
@@ -1,56 +0,0 @@
import { type NextPage } from "next";
import AppWrapper from "@/components/AppWrapper";
import { BsDiscord, BsGithub } from "react-icons/bs";
import { AiFillMail } from "react-icons/ai";
import Link from "next/link";
import type { IconType } from "react-icons";
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
const socials: {
icon: IconType;
href?: string;
hint?: string;
hideHint?: boolean;
}[] = [
{
icon: BsGithub,
href: "https://github.com/Xevion/",
},
{
icon: AiFillMail,
href: "mailto:xevion@xevion.dev",
hint: "xevion@xevion.dev",
},
{
icon: BsDiscord,
hint: "Xevion#8506",
},
];
const ContactPage: NextPage = () => {
return (
<AppWrapper>
<div className="my-10 flex w-full flex-col items-center">
<div className="mx-3 flex w-full max-w-[23rem] flex-col rounded-md border border-zinc-800 bg-zinc-800/50 p-5 sm:max-w-[25rem] lg:max-w-[30rem]">
<div className="flex justify-center gap-x-5 text-center">
{socials.map(({ icon: Icon, href, hint, hideHint }, index) => {
const inner = <Icon className="h-8 w-8" />;
return (
<Tippy key={index} disabled={hideHint} content={hint ?? href}>
{href != undefined ? (
<Link href={href}>{inner}</Link>
) : (
<span>{inner}</span>
)}
</Tippy>
);
})}
</div>
</div>
</div>
</AppWrapper>
);
};
export default ContactPage;
-84
View File
@@ -1,84 +0,0 @@
import AppWrapper from "@/components/AppWrapper";
import { env } from "@/env/server.mjs";
import directus from "@/utils/directus";
import { readSingleton } from "@directus/sdk";
import { GetStaticPropsResult, type NextPage } from "next";
import Head from "next/head";
import Link from "next/link";
import Balancer from "react-wrap-balancer";
type IndexProps = {
title: string;
tagline: string;
buttons: { text: string; href: string }[];
};
export async function getStaticProps(): Promise<
GetStaticPropsResult<IndexProps>
> {
const metadata = await directus.request(readSingleton("metadata"));
const resumeUrl = `${directus.url}assets/${metadata.resume}/${
metadata.resumeFilename ?? "resume.pdf"
}`;
return {
props: {
title: env.TITLE ?? "Xevion",
tagline: metadata.tagline,
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 },
],
},
revalidate: 60 * 60,
};
}
const Home: NextPage<IndexProps> = ({
title,
tagline,
buttons,
}: IndexProps) => {
return (
<>
<Head>
<title>Xevion.dev</title>
<meta name="description" content="My personal website." />
<link rel="icon" href="/favicon.ico" />
</Head>
<AppWrapper className="overflow-x-hidden" dotsClassName="animate-bg">
<div className="flex h-screen w-screen items-center justify-center overflow-hidden">
<div className="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>{tagline}</Balancer>
</div>
</div>
</div>
</AppWrapper>
</>
);
};
export default Home;
-88
View File
@@ -1,88 +0,0 @@
import AppWrapper from "@/components/AppWrapper";
import directus from "@/utils/directus";
import { cn } from "@/utils/helpers";
import { readItems } from "@directus/sdk";
import { GetStaticPropsResult, type NextPage } from "next";
import Link from "next/link";
import Balancer from "react-wrap-balancer";
type Props = {
projects: Awaited<ReturnType<typeof getProjects>>;
};
async function getProjects() {
return await directus.request(
readItems("project", {
fields: ["id", "name", "shortDescription", "icon", { links: ["url"] }],
sort: "-date_updated",
}),
);
}
export async function getStaticProps(): Promise<GetStaticPropsResult<Props>> {
return {
props: {
projects: await getProjects(),
},
};
}
const ProjectsPage: NextPage<Props> = ({ projects }) => {
return (
<AppWrapper dotsClassName="animate-bg-fast">
<div className="mx-auto grid grid-cols-1 justify-center gap-y-4 px-4 py-20 align-middle sm:grid-cols-2 md:max-w-[50rem] lg:max-w-[75rem] lg:grid-cols-3 lg:gap-y-9">
<div className="mb-3 text-center sm:col-span-2 md:mb-5 lg:col-span-3 lg:mb-7">
<h1 className="pb-3 font-hanken text-4xl text-zinc-200 opacity-100 md:text-5xl">
Projects
</h1>
<Balancer className="text-lg text-zinc-400">
created, maintained, or contributed to by me...
</Balancer>
</div>
{projects.map(
({ id, name, shortDescription: description, links, icon }) => {
const useAnchor = links?.length ?? 0 > 0;
const DynamicLink = useAnchor ? Link : "div";
const linkProps = useAnchor
? { href: links![0]!.url, target: "_blank", rel: "noreferrer" }
: {};
return (
<div className="max-w-fit" key={id}>
{/* @ts-expect-error because div can't accept href */}
<DynamicLink
key={name}
title={name}
className="flex items-center justify-start overflow-hidden rounded bg-black/10 pb-2.5 pl-3 pr-5 pt-1 text-zinc-400 transition-colors hover:bg-zinc-500/10 hover:text-zinc-50"
{...linkProps}
>
<div className="flex h-full w-14 items-center justify-center pr-5">
<i
className={cn(
icon ?? "fa-heart",
"fa-solid text-3xl text-opacity-80 saturate-0",
)}
></i>
</div>
<div className="overflow-hidden">
<span className="text-sm md:text-base lg:text-lg">
{name}
</span>
<p
className="truncate text-xs opacity-70 md:text-sm lg:text-base"
title={description}
>
{description}
</p>
</div>
</DynamicLink>
</div>
);
},
)}
</div>
</AppWrapper>
);
};
export default ProjectsPage;
+442
View File
@@ -0,0 +1,442 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
projects: Project;
technologies: Technology;
links: Link;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
projects: ProjectsSelect<false> | ProjectsSelect<true>;
technologies: TechnologiesSelect<false> | TechnologiesSelect<true>;
links: LinksSelect<false> | LinksSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
globals: {
metadata: Metadatum;
};
globalsSelect: {
metadata: MetadataSelect<false> | MetadataSelect<true>;
};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "projects".
*/
export interface Project {
id: number;
name: string;
description: string;
shortDescription: string;
icon?: string | null;
status: 'draft' | 'published' | 'archived';
featured?: boolean | null;
/**
* Automatically check GitHub for latest commits and update lastUpdated field
*/
autocheckUpdated?: boolean | null;
/**
* Automatically updated by cron job based on GitHub commits
*/
lastUpdated?: string | null;
/**
* Offset for WakaTime fetched data (optional)
*/
wakatimeOffset?: number | null;
bannerImage?: (number | null) | Media;
technologies?: (number | Technology)[] | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "technologies".
*/
export interface Technology {
id: number;
name: string;
url?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "links".
*/
export interface Link {
id: number;
url: string;
icon?: string | null;
description?: string | null;
project: number | Project;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'projects';
value: number | Project;
} | null)
| ({
relationTo: 'technologies';
value: number | Technology;
} | null)
| ({
relationTo: 'links';
value: number | Link;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "projects_select".
*/
export interface ProjectsSelect<T extends boolean = true> {
name?: T;
description?: T;
shortDescription?: T;
icon?: T;
status?: T;
featured?: T;
autocheckUpdated?: T;
lastUpdated?: T;
wakatimeOffset?: T;
bannerImage?: T;
technologies?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "technologies_select".
*/
export interface TechnologiesSelect<T extends boolean = true> {
name?: T;
url?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "links_select".
*/
export interface LinksSelect<T extends boolean = true> {
url?: T;
icon?: T;
description?: T;
project?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "metadata".
*/
export interface Metadatum {
id: number;
tagline: string;
resume: number | Media;
/**
* Optional: Override the filename for the resume (e.g., "resume.pdf")
*/
resumeFilename?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "metadata_select".
*/
export interface MetadataSelect<T extends boolean = true> {
tagline?: T;
resume?: T;
resumeFilename?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
+41
View File
@@ -0,0 +1,41 @@
// storage-adapter-import-placeholder
import { postgresAdapter } from "@payloadcms/db-postgres";
import { payloadCloudPlugin } from "@payloadcms/payload-cloud";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "path";
import { buildConfig } from "payload";
import { fileURLToPath } from "url";
import sharp from "sharp";
import { Users } from "./collections/Users";
import { Media } from "./collections/Media";
import { Projects } from "./collections/Projects";
import { Technologies } from "./collections/Technologies";
import { Links } from "./collections/Links";
import { Metadata } from "./globals/Metadata";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
admin: {
user: Users.slug,
},
collections: [Users, Media, Projects, Technologies, Links],
globals: [Metadata],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || "",
typescript: {
outputFile: path.resolve(dirname, "payload-types.ts"),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || "",
},
}),
sharp,
plugins: [
payloadCloudPlugin(),
// storage-adapter-placeholder
],
});
-27
View File
@@ -1,27 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { type inferAsyncReturnType } from "@trpc/server";
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
/**
* Replace this with an object if you want to pass things to createContextInner
*/
type CreateContextOptions = Record<string, never>;
/** Use this helper for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
**/
export const createContextInner = async (opts: CreateContextOptions) => {
return {};
};
/**
* This is the actual context you'll use in your router
* @link https://trpc.io/docs/context
**/
export const createContext = async (opts: CreateNextContextOptions) => {
return await createContextInner({});
};
export type Context = inferAsyncReturnType<typeof createContext>;
-9
View File
@@ -1,9 +0,0 @@
import { exampleRouter } from "@/server/trpc/router/example";
import { router } from "@/server/trpc/trpc";
export const appRouter = router({
example: exampleRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
-13
View File
@@ -1,13 +0,0 @@
import { z } from "zod";
import { router, publicProcedure } from "@/server/trpc/trpc";
export const exampleRouter = router({
hello: publicProcedure
.input(z.object({ text: z.string().nullish() }).nullish())
.query(({ input }) => {
return {
greeting: `Hello ${input?.text ?? "world"}`,
};
}),
});
-14
View File
@@ -1,14 +0,0 @@
import { Context } from "@/server/trpc/context";
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape }) {
return shape;
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
+2 -2
View File
@@ -20,7 +20,7 @@
html,
body {
@apply font-inter overflow-x-hidden;
@apply overflow-x-hidden font-inter text-white;
}
.item {
@@ -32,7 +32,7 @@ body {
.elements {
@apply hidden opacity-0 transition-all delay-100;
> * {
z-index: 30;
// z-index: 30;
min-height: 0;
}
}
-85
View File
@@ -1,85 +0,0 @@
import { env } from "@/env/server.mjs";
import { createDirectus, rest, staticToken } from "@directus/sdk";
export interface Schema {
metadata: Metadata;
project: Project[];
technology: Technology[];
link: Link[];
project_technology: ProjectTechnology[];
project_link: ProjectLink[];
}
export interface Technology {
id: string;
name: string;
url: string | null;
}
export interface ProjectTechnology {
id: string;
project_id: string;
technology_id: string;
}
export interface Project {
id: string;
// core fields
date_created: string;
date_updated: string;
sort: number; // used for ordering
status: string;
// relationships
links: number[] | ProjectLink[]; // One2Many
technologies: number[] | ProjectTechnology[]; // Many2Many
// relevant fields
icon: string | null;
name: string;
description: string;
shortDescription: string;
// misc fields
featured: boolean; // places the project in the 'featured' section
autocheckUpdated: boolean; // triggers a cron job to check for updates
wakatimeOffset: number | null; // offsets the WakaTime fetched data
bannerImage: string; // file identifier
}
export interface Link {
id: string;
project_id: string;
icon: string;
url: string;
description: string | null;
}
export interface ProjectLink {
id: string;
project_id: string;
sort: number;
icon: string;
url: string;
description: string | null;
}
export interface Metadata {
tagline: string;
resume: string;
resumeFilename: string;
}
const directus = createDirectus<Schema>("https://api.xevion.dev", {
globals: {
fetch: (input, init) => {
console.log(`${init.method?.toUpperCase()} ${input}`);
return fetch(input, init);
},
},
})
.with(staticToken(env.DIRECTUS_API_TOKEN))
.with(rest());
export default directus;
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import create from "@kodingdotninja/use-tailwind-breakpoint";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "@/../tailwind.config.cjs";
-42
View File
@@ -1,42 +0,0 @@
import { httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import superjson from "superjson";
import { type AppRouter } from "@/server/trpc/router/_app";
const getBaseUrl = () => {
if (typeof window !== "undefined") return ""; // browser should use relative url
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url
return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost
};
export const trpc = createTRPCNext<AppRouter>({
config() {
return {
transformer: superjson,
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
};
},
ssr: false,
});
/**
* Inference helper for inputs
* @example type HelloInput = RouterInputs['example']['hello']
**/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs
* @example type HelloOutput = RouterOutputs['example']['hello']
**/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
+102 -102
View File
@@ -4,8 +4,8 @@ const defaultTheme = require("tailwindcss/defaultTheme");
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
extend: {
colors: {
zinc: {
850: "#1D1D20",
},
@@ -14,113 +14,113 @@ module.exports = {
"10xl": "10rem",
},
typography: {
DEFAULT: {
css: {
"code::before": {
content: '""',
},
"code::after": {
content: '""',
},
},
},
quoteless: {
css: {
"blockquote p:first-of-type::before": { content: "none" },
"blockquote p:first-of-type::after": { content: "none" },
},
},
},
dropShadow: {
"extreme": "0 0 50px black",
},
fontFamily: {
DEFAULT: {
css: {
"code::before": {
content: '""',
},
"code::after": {
content: '""',
},
},
},
quoteless: {
css: {
"blockquote p:first-of-type::before": { content: "none" },
"blockquote p:first-of-type::after": { content: "none" },
},
},
},
dropShadow: {
extreme: "0 0 50px black",
},
fontFamily: {
inter: ['"Inter"', "sans-serif"],
roboto: ['"Roboto"', "sans-serif"],
mono: ['"Roboto Mono"', "monospace"],
hanken: ['"Hanken Grotesk"', "sans-serif"],
},
backgroundImage: {
"gradient-radial":
"radial-gradient(50% 50% at 50% 50%, var(--tw-gradient-stops))",
},
animation: {
"bg-fast": "fade 0.5s ease-in-out 0.5s forwards",
bg: "fade 1.2s ease-in-out 1.1s forwards",
},
backgroundImage: {
"gradient-radial":
"radial-gradient(50% 50% at 50% 50%, var(--tw-gradient-stops))",
},
animation: {
"bg-fast": "fade 0.5s ease-in-out 0.5s forwards",
bg: "fade 1.2s ease-in-out 1.1s forwards",
"fade-in": "fade-in 2.5s ease-in-out forwards",
title: "title 3s ease-out forwards",
"fade-left": "fade-left 3s ease-in-out forwards",
"fade-right": "fade-right 3s ease-in-out forwards",
},
keyframes: {
"fade": {
"0%": {
opacity: "0%",
},
"100%": {
opacity: "100%",
},
},
"fade-in": {
"0%": {
opacity: "0%",
},
"75%": {
opacity: "0%",
},
"100%": {
opacity: "100%",
},
},
"fade-left": {
"0%": {
transform: "translateX(100%)",
opacity: "0%",
},
title: "title 3s ease-out forwards",
"fade-left": "fade-left 3s ease-in-out forwards",
"fade-right": "fade-right 3s ease-in-out forwards",
},
keyframes: {
fade: {
"0%": {
opacity: "0%",
},
"100%": {
opacity: "100%",
},
},
"fade-in": {
"0%": {
opacity: "0%",
},
"75%": {
opacity: "0%",
},
"100%": {
opacity: "100%",
},
},
"fade-left": {
"0%": {
transform: "translateX(100%)",
opacity: "0%",
},
"30%": {
transform: "translateX(0%)",
opacity: "100%",
},
"100%": {
opacity: "0%",
},
},
"fade-right": {
"0%": {
transform: "translateX(-100%)",
opacity: "0%",
},
"30%": {
transform: "translateX(0%)",
opacity: "100%",
},
"100%": {
opacity: "0%",
},
},
"fade-right": {
"0%": {
transform: "translateX(-100%)",
opacity: "0%",
},
"30%": {
transform: "translateX(0%)",
opacity: "100%",
},
"100%": {
opacity: "0%",
},
},
title: {
"0%": {
"line-height": "0%",
"letter-spacing": "0.25em",
opacity: "0",
},
"25%": {
"line-height": "0%",
opacity: "0%",
},
"80%": {
opacity: "100%",
},
"30%": {
transform: "translateX(0%)",
opacity: "100%",
},
"100%": {
opacity: "0%",
},
},
title: {
"0%": {
"line-height": "0%",
"letter-spacing": "0.25em",
opacity: "0",
},
"25%": {
"line-height": "0%",
opacity: "0%",
},
"80%": {
opacity: "100%",
},
"100%": {
"line-height": "100%",
opacity: "100%",
},
},
},
},
},
"100%": {
"line-height": "100%",
opacity: "100%",
},
},
},
},
},
plugins: [require("@tailwindcss/typography")],
};
+12 -7
View File
@@ -1,11 +1,10 @@
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": ".",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
@@ -14,11 +13,17 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"noUncheckedIndexedAccess": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
"@/*": ["./src/*"],
"@payload-config": ["./src/payload.config.ts"]
},
"target": "ES2022"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+2
View File
@@ -1,4 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"version": 2,
"crons": [
{
"path": "/api/cron/updated",