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