mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 12:26:39 -06:00
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:
+17
-4
@@ -6,7 +6,20 @@
|
|||||||
|
|
||||||
# When adding additional env variables, the schema in /env/schema.mjs should be updated accordingly
|
# When adding additional env variables, the schema in /env/schema.mjs should be updated accordingly
|
||||||
|
|
||||||
# Example:
|
# Payload CMS
|
||||||
# SERVERVAR=foo
|
PAYLOAD_SECRET=your-secret-key-here
|
||||||
# NEXT_PUBLIC_CLIENTVAR=bar
|
DATABASE_URI=postgresql://xevion:xevion_dev_password@localhost:5432/xevion_dev
|
||||||
DIRECTUS_REVALIDATE_KEY=
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -10,7 +10,15 @@ const compat = new FlatCompat({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eslintConfig = [
|
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;
|
export default eslintConfig;
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { withPayload } from "@payloadcms/next/withPayload";
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import withPlaiceholder from "@plaiceholder/next";
|
import withPlaiceholder from "@plaiceholder/next";
|
||||||
@@ -91,4 +92,4 @@ const config = {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export default withPlaiceholder(config);
|
export default withPayload(withPlaiceholder(config));
|
||||||
|
|||||||
+24
-20
@@ -2,54 +2,57 @@
|
|||||||
"name": "xevion.dev",
|
"name": "xevion.dev",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^18.0.3",
|
"@floating-ui/react": "^0.27.16",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@kodingdotninja/use-tailwind-breakpoint": "^1.0.0",
|
"@kodingdotninja/use-tailwind-breakpoint": "^1.0.0",
|
||||||
"@next/eslint-plugin-next": "^15.1.1",
|
"@next/eslint-plugin-next": "^15.1.1",
|
||||||
"@octokit/core": "^6.1.2",
|
"@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",
|
"@plaiceholder/next": "^3.0.0",
|
||||||
"@tailwindcss/typography": "^0.5.8",
|
"@tailwindcss/typography": "^0.5.8",
|
||||||
"@tanstack/react-query": "^4.16.0",
|
"@tanstack/react-query": "^5.90",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@vercel/analytics": "^1.5.0",
|
||||||
"@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",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cssnano": "^7.0.6",
|
"cssnano": "^7.0.6",
|
||||||
"next": "^15.1.1",
|
"graphql": "^16.11.0",
|
||||||
|
"next": "^15.5.6",
|
||||||
"p5i": "^0.6.0",
|
"p5i": "^0.6.0",
|
||||||
|
"payload": "^3.61.1",
|
||||||
"plaiceholder": "^3.0.0",
|
"plaiceholder": "^3.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"react": "18.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-icons": "^4.10.1",
|
"react-icons": "^4.10.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-wrap-balancer": "^0.4.0",
|
"react-wrap-balancer": "^1",
|
||||||
"sass": "^1.56.2",
|
"sass": "^1.56.2",
|
||||||
"sharp": "^0.32.1",
|
"sharp": "^0.34",
|
||||||
"superjson": "1.9.1",
|
"superjson": "^2.2",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.1",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@eslint/js": "^9.38.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"autoprefixer": "^10.4.7",
|
"autoprefixer": "^10.4.7",
|
||||||
"eslint": "^9",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "15.1.1",
|
"eslint-config-next": "^15.1.1",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
@@ -57,5 +60,6 @@
|
|||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "6.11.3"
|
"initVersion": "6.11.3"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4704
-610
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const importMap = {};
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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 @@
|
|||||||
/**
|
import { NextResponse } from "next/server";
|
||||||
* This is a cron job handler for acquiring the latest 'updated' data for the site's projects.
|
import { getPayload } from "payload";
|
||||||
*
|
import config from "../../../../payload.config";
|
||||||
* 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 { Octokit } from "@octokit/core";
|
import { Octokit } from "@octokit/core";
|
||||||
import { isFulfilled, isRejected } from "@/utils/types";
|
|
||||||
|
|
||||||
const octokit = new Octokit({
|
const octokit = new Octokit({
|
||||||
auth: process.env.GITHUB_API_TOKEN,
|
auth: process.env.GITHUB_API_TOKEN,
|
||||||
@@ -30,29 +14,40 @@ const octokit = new Octokit({
|
|||||||
});
|
});
|
||||||
|
|
||||||
type ProjectResult = {
|
type ProjectResult = {
|
||||||
id: string;
|
id: number;
|
||||||
previousUpdated: Date | null;
|
previousUpdated: Date | null;
|
||||||
latestUpdated: Date | null;
|
latestUpdated: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRepository(url: string): [string, string] | null {
|
function getRepository(url: string): [string, string] | null {
|
||||||
const pattern = /github.com\/([^/]+)\/([^/]+)/;
|
const pattern = /github\.com\/([^/]+)\/([^/]+)/;
|
||||||
const match = pattern.exec(url);
|
const match = url.match(pattern);
|
||||||
|
|
||||||
if (match === null) return null;
|
if (match === null) return null;
|
||||||
return [match[1]!, match[2]!];
|
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({
|
async function handleProject({
|
||||||
id: project_id,
|
id: project_id,
|
||||||
urls,
|
urls,
|
||||||
date_updated: previousUpdated,
|
date_updated: previousUpdated,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: number;
|
||||||
urls: string[];
|
urls: string[];
|
||||||
date_updated: Date | null;
|
date_updated: Date | null;
|
||||||
}): Promise<ProjectResult> {
|
}): Promise<ProjectResult> {
|
||||||
// Extract the branches from each URL
|
|
||||||
const allBranches = await Promise.all(
|
const allBranches = await Promise.all(
|
||||||
urls.map(async (url) => {
|
urls.map(async (url) => {
|
||||||
const details = getRepository(url);
|
const details = getRepository(url);
|
||||||
@@ -60,7 +55,6 @@ async function handleProject({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Handle deduplication of repository targets
|
|
||||||
const [owner, repo] = details;
|
const [owner, repo] = details;
|
||||||
const branches = await octokit.request(
|
const branches = await octokit.request(
|
||||||
"GET /repos/{owner}/{repo}/branches",
|
"GET /repos/{owner}/{repo}/branches",
|
||||||
@@ -81,7 +75,6 @@ async function handleProject({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the latest commit date for each branch (flattened)
|
|
||||||
const latestCommits = allBranches
|
const latestCommits = allBranches
|
||||||
.flat()
|
.flat()
|
||||||
.map(async ({ owner, repo, branch }) => {
|
.map(async ({ owner, repo, branch }) => {
|
||||||
@@ -99,7 +92,6 @@ async function handleProject({
|
|||||||
);
|
);
|
||||||
const latestCommit = commits.data[0];
|
const latestCommit = commits.data[0];
|
||||||
|
|
||||||
// Commits not returned
|
|
||||||
if (latestCommit == null) {
|
if (latestCommit == null) {
|
||||||
console.warn({
|
console.warn({
|
||||||
target: `${owner}/${repo}@${branch}`,
|
target: `${owner}/${repo}@${branch}`,
|
||||||
@@ -108,7 +100,6 @@ async function handleProject({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle missing commit data in unpredictable cases
|
|
||||||
if (latestCommit.commit.author == null) {
|
if (latestCommit.commit.author == null) {
|
||||||
console.warn({
|
console.warn({
|
||||||
target: `${owner}/${repo}@${branch}`,
|
target: `${owner}/${repo}@${branch}`,
|
||||||
@@ -134,13 +125,10 @@ async function handleProject({
|
|||||||
|
|
||||||
const results = await Promise.allSettled(latestCommits);
|
const results = await Promise.allSettled(latestCommits);
|
||||||
|
|
||||||
// Handle the promises that failed
|
|
||||||
results.filter(isRejected).forEach((result) => {
|
results.filter(isRejected).forEach((result) => {
|
||||||
// TODO: Add more context to the error message
|
|
||||||
console.error("Failed to fetch latest commit date", result.reason);
|
console.error("Failed to fetch latest commit date", result.reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find the latest commit date
|
|
||||||
const latestUpdated = results
|
const latestUpdated = results
|
||||||
.filter(isFulfilled)
|
.filter(isFulfilled)
|
||||||
.map((v) => v.value)
|
.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")) {
|
if (latestUpdated != null && latestUpdated < new Date("2015-01-01")) {
|
||||||
console.error("Invalid commit date acquired", latestUpdated);
|
console.error("Invalid commit date acquired", latestUpdated);
|
||||||
return {
|
return {
|
||||||
@@ -171,15 +158,18 @@ async function handleProject({
|
|||||||
|
|
||||||
const result = { id: project_id, previousUpdated, latestUpdated: null };
|
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) {
|
if (previousUpdated == null || latestUpdated > previousUpdated) {
|
||||||
await directus.request(
|
const payloadConfig = await config;
|
||||||
updateItem("project", project_id, {
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
date_updated: latestUpdated,
|
|
||||||
}),
|
await payload.update({
|
||||||
);
|
collection: "projects",
|
||||||
|
id: project_id,
|
||||||
|
data: {
|
||||||
|
lastUpdated: latestUpdated.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 'latestUpdated' is not null ONLY if the project was actually updated
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
latestUpdated,
|
latestUpdated,
|
||||||
@@ -189,26 +179,24 @@ async function handleProject({
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(
|
export async function GET(req: Request) {
|
||||||
req: NextApiRequest,
|
const { CRON_SECRET, GITHUB_API_TOKEN } = process.env;
|
||||||
res: NextApiResponse,
|
if (!CRON_SECRET || !GITHUB_API_TOKEN) {
|
||||||
) {
|
return NextResponse.json(
|
||||||
// Check for the required environment variables
|
{ error: "Missing environment variables" },
|
||||||
const { CRON_SECRET, GITHUB_API_TOKEN, DIRECTUS_API_TOKEN } = process.env;
|
{ status: 500 },
|
||||||
if (!CRON_SECRET || !GITHUB_API_TOKEN || !DIRECTUS_API_TOKEN) {
|
);
|
||||||
res.status(500).json({ error: "Missing environment variables" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the cron request is authenticated
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers.get("authorization");
|
||||||
const secretQueryParam = req.query.secret;
|
const url = new URL(req.url);
|
||||||
|
const secretQueryParam = url.searchParams.get("secret");
|
||||||
if (
|
if (
|
||||||
authHeader !== `Bearer ${CRON_SECRET}` &&
|
authHeader !== `Bearer ${CRON_SECRET}` &&
|
||||||
secretQueryParam !== CRON_SECRET
|
secretQueryParam !== CRON_SECRET
|
||||||
) {
|
) {
|
||||||
res.status(401).json({ error: "Unauthorized" });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,67 +206,61 @@ export default async function handler(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch the list of all projects including their link URLs.
|
const payloadConfig = await config;
|
||||||
const projects = await directus.request(
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
readItems("project", {
|
|
||||||
fields: [
|
const { docs: projects } = await payload.find({
|
||||||
"id",
|
collection: "projects",
|
||||||
"name",
|
});
|
||||||
"autocheckUpdated",
|
|
||||||
"date_updated",
|
const { docs: allLinks } = await payload.find({
|
||||||
{ links: ["url"] },
|
collection: "links",
|
||||||
],
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter the list only for projects with 'autocheck_update' enabled and any 'github.com' link.
|
|
||||||
const eligibleProjects = projects
|
const eligibleProjects = projects
|
||||||
.map((project) => {
|
.map((project) => {
|
||||||
// Skip projects that don't have autocheckUpdated enabled.
|
|
||||||
if (!project.autocheckUpdated) return null;
|
if (!project.autocheckUpdated) return null;
|
||||||
|
|
||||||
// Acquire the URL from the link, then filter out any non-GitHub URLs.
|
const urls = allLinks
|
||||||
const urls = project
|
.filter((link) => {
|
||||||
.links!.map((link) => {
|
const projectId =
|
||||||
return (<ProjectLink>link).url;
|
typeof link.project === "number"
|
||||||
|
? link.project
|
||||||
|
: link.project.id;
|
||||||
|
return projectId === project.id;
|
||||||
})
|
})
|
||||||
|
.map((link) => link.url)
|
||||||
.filter((url) => url.includes("github.com"));
|
.filter((url) => url.includes("github.com"));
|
||||||
|
|
||||||
// Skip projects that don't have any GitHub URLs.
|
|
||||||
if (urls.length === 0) return null;
|
if (urls.length === 0) return null;
|
||||||
|
|
||||||
// Return the project's most important data for further processing.
|
|
||||||
return {
|
return {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
date_updated: project.date_updated,
|
date_updated:
|
||||||
|
project.lastUpdated != null ? new Date(project.lastUpdated) : null,
|
||||||
urls,
|
urls,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
// null values are still included in the array, so filter them out.
|
|
||||||
.filter((project) => project !== null);
|
.filter((project) => project !== null);
|
||||||
|
|
||||||
// For each project, query the GitHub API for the latest commit date on all branches.
|
|
||||||
const projectPromises = eligibleProjects.map((project) =>
|
const projectPromises = eligibleProjects.map((project) =>
|
||||||
handleProject({
|
handleProject({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
urls: project.urls,
|
urls: project.urls,
|
||||||
date_updated:
|
date_updated: project.date_updated,
|
||||||
project.date_updated != null ? new Date(project.date_updated) : null,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for all project promises to resolve
|
|
||||||
const results = await Promise.allSettled(projectPromises);
|
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;
|
const isFailed = results.filter(isRejected).length > results.length * 0.1;
|
||||||
|
|
||||||
type Response = {
|
type Response = {
|
||||||
request_count: number;
|
request_count: number;
|
||||||
errors: { project_name: string; reason: string }[];
|
errors: { project_name: string; reason: string }[];
|
||||||
ignored: string[];
|
ignored: number[];
|
||||||
changed: { project_name: string; previous: Date | null; latest: Date }[];
|
changed: { project_name: number; previous: Date | null; latest: Date }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const fulfilled = results.filter(isFulfilled);
|
const fulfilled = results.filter(isFulfilled);
|
||||||
@@ -286,7 +268,6 @@ export default async function handler(
|
|||||||
const response: Response = {
|
const response: Response = {
|
||||||
request_count,
|
request_count,
|
||||||
errors: results.filter(isRejected).map((r) => ({
|
errors: results.filter(isRejected).map((r) => ({
|
||||||
// TODO: Fix this project name
|
|
||||||
project_name: "unknown",
|
project_name: "unknown",
|
||||||
reason: r.reason,
|
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) {
|
} catch (error) {
|
||||||
res.status(500).json({ error });
|
return NextResponse.json({ error }, { status: 500 });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/utils/helpers";
|
import { cn } from "@/utils/helpers";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type { FunctionComponent, ReactNode } from "react";
|
import type { FunctionComponent, ReactNode } from "react";
|
||||||
@@ -8,10 +10,7 @@ type WrapperProps = {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DotsDynamic = dynamic(
|
const DotsDynamic = dynamic(() => import("@/components/Dots"), { ssr: false });
|
||||||
() => import('@/components/Dots'),
|
|
||||||
{ ssr: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
const AppWrapper: FunctionComponent<WrapperProps> = ({
|
const AppWrapper: FunctionComponent<WrapperProps> = ({
|
||||||
children,
|
children,
|
||||||
@@ -20,10 +19,7 @@ const AppWrapper: FunctionComponent<WrapperProps> = ({
|
|||||||
}: WrapperProps) => {
|
}: WrapperProps) => {
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn("relative min-h-screen bg-black text-zinc-50", className)}
|
||||||
"min-h-screen text-zinc-50",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<DotsDynamic className={dotsClassName} />
|
<DotsDynamic className={dotsClassName} />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+42
-26
@@ -1,15 +1,15 @@
|
|||||||
import { cn } from '@/utils/helpers';
|
import { cn } from "@/utils/helpers";
|
||||||
import { p5i, P5I } from 'p5i';
|
import { p5i, P5I } from "p5i";
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface DotsProps {
|
interface DotsProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Dots = ({
|
const Dots = ({ className }: DotsProps) => {
|
||||||
className,
|
|
||||||
}: DotsProps) => {
|
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const w = useRef(window.innerWidth);
|
||||||
|
const h = useRef(window.innerHeight);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mount,
|
mount,
|
||||||
@@ -23,8 +23,6 @@ const Dots = ({
|
|||||||
TWO_PI,
|
TWO_PI,
|
||||||
} = p5i();
|
} = p5i();
|
||||||
|
|
||||||
let w = window.innerWidth;
|
|
||||||
let h = window.innerHeight;
|
|
||||||
const offsetY = window.scrollY;
|
const offsetY = window.scrollY;
|
||||||
|
|
||||||
const SCALE = 400;
|
const SCALE = 400;
|
||||||
@@ -39,11 +37,11 @@ const Dots = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pointIds = new Set<string>();
|
const pointIds = new Set<string>();
|
||||||
const points: { x: number, y: number, opacity: number }[] = [];
|
const points: { x: number; y: number; opacity: number }[] = [];
|
||||||
|
|
||||||
function addPoints() {
|
function addPoints() {
|
||||||
for (let x = -SPACING / 2; x < w + SPACING; x += SPACING) {
|
for (let x = -SPACING / 2; x < w.current + SPACING; x += SPACING) {
|
||||||
for (let y = -SPACING / 2; y < h + offsetY + SPACING; y += SPACING) {
|
for (let y = -SPACING / 2; y < h.current + offsetY + SPACING; y += SPACING) {
|
||||||
const id = `${x}-${y}`;
|
const id = `${x}-${y}`;
|
||||||
if (pointIds.has(id)) continue;
|
if (pointIds.has(id)) continue;
|
||||||
pointIds.add(id);
|
pointIds.add(id);
|
||||||
@@ -53,10 +51,17 @@ const Dots = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup({ createCanvas, stroke, frameRate, background, noFill, noiseSeed }: P5I) {
|
function setup({
|
||||||
createCanvas(w, h);
|
createCanvas,
|
||||||
background('#000000');
|
stroke,
|
||||||
stroke('rgba(170, 170, 170, 0.05)');
|
frameRate,
|
||||||
|
background,
|
||||||
|
noFill,
|
||||||
|
noiseSeed,
|
||||||
|
}: P5I) {
|
||||||
|
createCanvas(w.current, h.current);
|
||||||
|
background("#000000");
|
||||||
|
stroke("rgba(170, 170, 170, 0.05)");
|
||||||
noFill();
|
noFill();
|
||||||
|
|
||||||
frameRate(TARGET_FRAMERATE);
|
frameRate(TARGET_FRAMERATE);
|
||||||
@@ -66,7 +71,7 @@ const Dots = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function draw({ circle, frameCount }: P5I) {
|
function draw({ circle, frameCount }: P5I) {
|
||||||
background('#000000');
|
background("#000000");
|
||||||
const t = (frameCount / 80) * TIMESCALE;
|
const t = (frameCount / 80) * TIMESCALE;
|
||||||
|
|
||||||
// if (frameCount % 10000) console.log(frameRate());
|
// if (frameCount % 10000) console.log(frameRate());
|
||||||
@@ -81,7 +86,12 @@ const Dots = ({
|
|||||||
// if (center_distance < 350)
|
// if (center_distance < 350)
|
||||||
// opacity = 0;
|
// opacity = 0;
|
||||||
// opacity =
|
// opacity =
|
||||||
stroke(200, 200, 200, (Math.abs(cos(rad)) * 0.8 + 0.1) * p.opacity * 255 * OPACITY);
|
stroke(
|
||||||
|
200,
|
||||||
|
200,
|
||||||
|
200,
|
||||||
|
(Math.abs(cos(rad)) * 0.8 + 0.1) * p.opacity * 255 * OPACITY,
|
||||||
|
);
|
||||||
circle(nx, ny - offsetY, 1);
|
circle(nx, ny - offsetY, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,24 +106,30 @@ const Dots = ({
|
|||||||
restart();
|
restart();
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
w = window.innerWidth;
|
w.current = window.innerWidth;
|
||||||
h = window.innerHeight;
|
h.current = window.innerHeight;
|
||||||
resizeCanvas(w, h);
|
resizeCanvas(w.current, h.current);
|
||||||
addPoints();
|
addPoints();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener("resize", handleResize);
|
||||||
unmount();
|
unmount();
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div ref={canvasRef} className={cn(
|
return (
|
||||||
"fixed left-0 right-0 top-0 bottom-0 pointer-events-none -z-10 opacity-0",
|
<div
|
||||||
className
|
ref={canvasRef}
|
||||||
)} />;
|
className={cn(
|
||||||
|
"pointer-events-none fixed bottom-0 left-0 right-0 top-0 z-0 opacity-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dots;
|
export default Dots;
|
||||||
@@ -62,9 +62,7 @@ const ItemCard = ({
|
|||||||
fill
|
fill
|
||||||
src={banner}
|
src={banner}
|
||||||
quality={bannerSettings?.quality ?? 75}
|
quality={bannerSettings?.quality ?? 75}
|
||||||
className={(loaded) =>
|
className={(loaded) => cn("object-cover", loaded ? null : "blur-xl")}
|
||||||
cn("object-cover", loaded ? null : "blur-xl")
|
|
||||||
}
|
|
||||||
alt={`Banner for ${title}`}
|
alt={`Banner for ${title}`}
|
||||||
/>
|
/>
|
||||||
<div className="elements m-2 grid h-full grid-cols-12 px-1 sm:px-4">
|
<div className="elements m-2 grid h-full grid-cols-12 px-1 sm:px-4">
|
||||||
|
|||||||
Vendored
+4
-3
@@ -8,9 +8,10 @@ import { z } from "zod";
|
|||||||
export const serverSchema = z.object({
|
export const serverSchema = z.object({
|
||||||
CRON_SECRET: z.string().nullish(),
|
CRON_SECRET: z.string().nullish(),
|
||||||
GITHUB_API_TOKEN: z.string(),
|
GITHUB_API_TOKEN: z.string(),
|
||||||
DIRECTUS_API_TOKEN: z.string(),
|
PAYLOAD_SECRET: z.string(),
|
||||||
DIRECTUS_REVALIDATE_KEY: z.string(),
|
DATABASE_URI: z.string(),
|
||||||
HEALTHCHECK_SECRET: z.string(), // Added for healthcheck route
|
PAYLOAD_REVALIDATE_KEY: z.string().optional(),
|
||||||
|
HEALTHCHECK_SECRET: z.string(),
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||||
TITLE: z.preprocess((value) => {
|
TITLE: z.preprocess((value) => {
|
||||||
if (value === undefined || value === "") return null;
|
if (value === undefined || value === "") return null;
|
||||||
|
|||||||
@@ -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")',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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 <></>;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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>;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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"}`,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@apply font-inter overflow-x-hidden;
|
@apply overflow-x-hidden font-inter text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
@@ -32,7 +32,7 @@ body {
|
|||||||
.elements {
|
.elements {
|
||||||
@apply hidden opacity-0 transition-all delay-100;
|
@apply hidden opacity-0 transition-all delay-100;
|
||||||
> * {
|
> * {
|
||||||
z-index: 30;
|
// z-index: 30;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
import create from "@kodingdotninja/use-tailwind-breakpoint";
|
import create from "@kodingdotninja/use-tailwind-breakpoint";
|
||||||
import resolveConfig from "tailwindcss/resolveConfig";
|
import resolveConfig from "tailwindcss/resolveConfig";
|
||||||
import tailwindConfig from "@/../tailwind.config.cjs";
|
import tailwindConfig from "@/../tailwind.config.cjs";
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
+2
-2
@@ -32,7 +32,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
dropShadow: {
|
dropShadow: {
|
||||||
"extreme": "0 0 50px black",
|
extreme: "0 0 50px black",
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
inter: ['"Inter"', "sans-serif"],
|
inter: ['"Inter"', "sans-serif"],
|
||||||
@@ -53,7 +53,7 @@ module.exports = {
|
|||||||
"fade-right": "fade-right 3s ease-in-out forwards",
|
"fade-right": "fade-right 3s ease-in-out forwards",
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"fade": {
|
fade: {
|
||||||
"0%": {
|
"0%": {
|
||||||
opacity: "0%",
|
opacity: "0%",
|
||||||
},
|
},
|
||||||
|
|||||||
+12
-7
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2017",
|
"baseUrl": ".",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
@@ -14,11 +13,17 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"plugins": [
|
||||||
"paths": {
|
{
|
||||||
"@/*": ["./src/*"]
|
"name": "next"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@payload-config": ["./src/payload.config.ts"]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.mjs"],
|
"target": "ES2022"
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
|
"version": 2,
|
||||||
"crons": [
|
"crons": [
|
||||||
{
|
{
|
||||||
"path": "/api/cron/updated",
|
"path": "/api/cron/updated",
|
||||||
|
|||||||
Reference in New Issue
Block a user