Burn it all to the ground and start with bun and a reorg
This commit is contained in:
@@ -1,24 +1,27 @@
|
||||
import { signIn } from "@/lib/auth";
|
||||
import type React from "react";
|
||||
|
||||
|
||||
export default function Auth(props: {
|
||||
searchParams: Promise<{ callbackUrl: string | undefined }>
|
||||
searchParams: Promise<{ callbackUrl: string | undefined }>;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<form
|
||||
className="w-40 mx-auto"
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("authelia", {
|
||||
redirectTo: (await props.searchParams)?.callbackUrl ?? "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button type="submit"
|
||||
className={`rounded-lg dark:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-transparent`}
|
||||
>
|
||||
<span>Sign in with Authelia</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<form
|
||||
className="mx-auto w-40"
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("authelia", {
|
||||
redirectTo: (await props.searchParams)?.callbackUrl ?? "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className={
|
||||
"rounded-lg border-transparent px-2 py-2 font-normal transition-colors duration-100 dark:bg-dracula-bg-light dark:text-white"
|
||||
}
|
||||
>
|
||||
<span>Sign in with Authelia</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import React from 'react';
|
||||
import Cv from '@/components/cv';
|
||||
import Cv from "@/app/_components/cv";
|
||||
import type React from "react";
|
||||
|
||||
export default function CvPage(): React.JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<div className='flex flex-row justify-center pb-4'>
|
||||
<button className='py-2 px-4 border'>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
<Cv/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row justify-center pb-4">
|
||||
<button type="button" className="border px-4 py-2">Download</button>
|
||||
</div>
|
||||
<Cv />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import NavBar from '@/components/navbar';
|
||||
import Footer from '@/components/footer';
|
||||
|
||||
import "../globals.css";
|
||||
import Footer from "@/app/_components/footer";
|
||||
import NavBar from "@/app/_components/navbar";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}>): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<NavBar/>
|
||||
<main className="px-6 py-4 w-full flex-1 align-middle overflow-y-scroll scrollbar scrollbar-thumb-dracula-purple scrollbar-track-dracula-bg-light">
|
||||
return (
|
||||
<>
|
||||
<NavBar />
|
||||
|
||||
<main className="mx-auto w-full flex-1 px-6 py-4 align-middle lg:max-w-5xl">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{/* <main className="px-6 py-4 w-full flex-1 align-middle overflow-y-scroll scrollbar scrollbar-thumb-dracula-purple scrollbar-track-dracula-bg-light">
|
||||
<div className="mx-auto w-full align-middle lg:max-w-5xl ">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Footer/>
|
||||
</>
|
||||
);
|
||||
</main> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import HomeMdx from '@/markdown/page.mdx';
|
||||
import HomeMdx from "@/markdown/page.mdx";
|
||||
|
||||
export default function Home(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<HomeMdx/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<HomeMdx />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// import { auth } from "@/server/auth";
|
||||
// import { HydrateClient } from "@/trpc/server";
|
||||
|
||||
// export default async function Home() {
|
||||
// const session = await auth();
|
||||
|
||||
// if (session?.user) {
|
||||
// // void api.post.getLatest.prefetch();
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <HydrateClient>
|
||||
// <main className="flex min-h-screen flex-col items-center justify-center">
|
||||
// </main>
|
||||
// </HydrateClient>
|
||||
// );
|
||||
// }
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import Image from "next/image";
|
||||
import FilteredLightbox from "@/components/lightbox";
|
||||
import { trpc } from "@/trpc/server";
|
||||
import { TRPCProvider } from "@/trpc/client";
|
||||
import FilteredLightbox from "@/app/_components/lightbox";
|
||||
import { api } from "@/trpc/server";
|
||||
|
||||
export default async function Photos(): Promise<React.JSX.Element> {
|
||||
const { data: images } = await trpc.photos.list();
|
||||
const { data: images } = await api.photos.list();
|
||||
|
||||
return (
|
||||
<div className="mx-auto">
|
||||
<TRPCProvider>
|
||||
<FilteredLightbox imageData={images}>
|
||||
{images.map((image) => (
|
||||
<Image
|
||||
key={image.src}
|
||||
alt={image.src}
|
||||
src={image.src}
|
||||
className="object-contain h-60 w-80"
|
||||
sizes="100vw"
|
||||
loading="lazy"
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
blurDataURL={image.blur}
|
||||
placeholder="blur"
|
||||
return (
|
||||
<div className="mx-auto">
|
||||
<FilteredLightbox imageData={images}>
|
||||
{images.map((image) => (
|
||||
<Image
|
||||
key={image.src}
|
||||
alt={image.src}
|
||||
src={image.src}
|
||||
className="h-60 w-80 object-contain"
|
||||
sizes="100vw"
|
||||
loading="lazy"
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
blurDataURL={image.blur}
|
||||
placeholder="blur"
|
||||
/>
|
||||
))}
|
||||
</FilteredLightbox>
|
||||
</TRPCProvider>
|
||||
</div>
|
||||
);
|
||||
</FilteredLightbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { glob } from "glob";
|
||||
import dynamic, { LoaderComponent } from "next/dynamic";
|
||||
import React from "react";
|
||||
import dynamic, { type LoaderComponent } from "next/dynamic";
|
||||
import type React from "react";
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export async function generateStaticParams(): Promise<{ slug: string[] }[]> {
|
||||
const posts = await glob(
|
||||
`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`,
|
||||
{
|
||||
nodir: true,
|
||||
}
|
||||
);
|
||||
const posts = await glob(
|
||||
`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`,
|
||||
{
|
||||
nodir: true,
|
||||
},
|
||||
);
|
||||
|
||||
const slugs = posts.map((post) => ({
|
||||
slug: [post.split("/").at(-1)!.slice(0, -4)],
|
||||
}));
|
||||
const slugs = posts.map((post) => ({
|
||||
slug: [post.split("/").at(-1)?.slice(0, -4)],
|
||||
}));
|
||||
|
||||
return slugs;
|
||||
return slugs;
|
||||
}
|
||||
|
||||
export default async function Post({
|
||||
params,
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<React.JSX.Element> {
|
||||
const mdxFile = await import(
|
||||
`../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx`
|
||||
) as LoaderComponent<unknown>;
|
||||
const Post = dynamic(() => mdxFile);
|
||||
return <Post />;
|
||||
const mdxFile = (await import(
|
||||
`../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx`
|
||||
)) as LoaderComponent<unknown>;
|
||||
const Post = dynamic(() => mdxFile);
|
||||
return <Post />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
import type React from "react";
|
||||
|
||||
export default function Post({children}: {children: React.JSX.Element}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
export default function Post({
|
||||
children,
|
||||
}: { children: React.JSX.Element }): React.JSX.Element {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
import { getBaseUrl } from "@/lib/base-url";
|
||||
import { glob } from "glob";
|
||||
import { getCurrentUrl } from "@/lib/current-url";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Link from "next/link";
|
||||
|
||||
type postDetails = {
|
||||
link: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
date: string;
|
||||
coverImage: string;
|
||||
blurb: string;
|
||||
shortBlurb: string;
|
||||
tags: string[];
|
||||
};
|
||||
link: string;
|
||||
metadata: {
|
||||
title: string;
|
||||
date: string;
|
||||
coverImage: string;
|
||||
blurb: string;
|
||||
shortBlurb: string;
|
||||
tags: string[];
|
||||
};
|
||||
};
|
||||
|
||||
async function loadPostDetails(): Promise<postDetails[]> {
|
||||
const posts = await glob(
|
||||
`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`,
|
||||
{
|
||||
nodir: true,
|
||||
}
|
||||
);
|
||||
const posts = await glob(
|
||||
`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`,
|
||||
{
|
||||
nodir: true,
|
||||
},
|
||||
);
|
||||
|
||||
const loadPostData = posts.map(async (post) => {
|
||||
const slug = [post.split("/").at(-1)!.slice(0, -4)];
|
||||
const mdxFile = await import(
|
||||
`../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx`
|
||||
) as postDetails;
|
||||
return {
|
||||
link: getCurrentUrl() + "/posts/" + slug.join("/"),
|
||||
metadata: mdxFile.metadata,
|
||||
};
|
||||
});
|
||||
const loadPostData = posts.map(async (post: string) => {
|
||||
const slug = [post.split("/").at(-1)?.slice(0, -4)];
|
||||
const mdxFile = (await import(
|
||||
`../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx`
|
||||
)) as postDetails;
|
||||
return {
|
||||
link: `${getBaseUrl()}/posts/${slug.join("/")}`,
|
||||
metadata: mdxFile.metadata,
|
||||
};
|
||||
});
|
||||
|
||||
const postData = await Promise.all(loadPostData);
|
||||
return postData;
|
||||
const postData = await Promise.all(loadPostData);
|
||||
return postData;
|
||||
}
|
||||
|
||||
const getPosts = unstable_cache(loadPostDetails, ["posts"], {
|
||||
revalidate: false,
|
||||
revalidate: false,
|
||||
});
|
||||
|
||||
export default async function Posts(): Promise<React.JSX.Element> {
|
||||
const postDetails = await getPosts();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{postDetails.map((post) => {
|
||||
return (
|
||||
<div key={post.link}>
|
||||
<div className="prose dark:prose-invert mx-auto">
|
||||
<h2>
|
||||
<Link href={post.link}>{post.metadata.title}</Link>
|
||||
</h2>
|
||||
<div className="flex flex-row">
|
||||
{post.metadata.tags.map((tag) => {
|
||||
return (
|
||||
<div key={`${post.link}_${tag}`}>
|
||||
<span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">
|
||||
{tag}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p>{post.metadata.blurb}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const postDetails = await getPosts();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{postDetails.map((post) => {
|
||||
return (
|
||||
<div key={post.link}>
|
||||
<div className="prose dark:prose-invert mx-auto">
|
||||
<h2>
|
||||
<Link href={post.link}>{post.metadata.title}</Link>
|
||||
</h2>
|
||||
<div className="flex flex-row">
|
||||
{post.metadata.tags.map((tag) => {
|
||||
return (
|
||||
<div key={`${post.link}_${tag}`}>
|
||||
<span className="me-2 select-none rounded border border-dracula-pink px-2.5 py-1 text-sm dark:bg-dracula-bg-darker dark:text-dracula-pink">
|
||||
{tag}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p>{post.metadata.blurb}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React from 'react';
|
||||
import type React from "react";
|
||||
|
||||
import Cv from '@/components/cv';
|
||||
import Cv from "@/components/cv";
|
||||
|
||||
export default function CvPrint(): React.JSX.Element {
|
||||
return (
|
||||
<Cv/>
|
||||
);
|
||||
return <Cv />;
|
||||
}
|
||||
|
||||
37
src/app/_components/auth/login.tsx
Normal file
37
src/app/_components/auth/login.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getBaseUrl } from "@/lib/base-url";
|
||||
import { auth, signIn, signOut } from "@/server/auth";
|
||||
import UserCircleIcon from "@heroicons/react/24/outline/UserCircleIcon";
|
||||
|
||||
// TODO
|
||||
export default async function LogIn(): Promise<React.JSX.Element | undefined> {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
if (session?.user) {
|
||||
await signOut({
|
||||
redirectTo: `${getBaseUrl()}/`,
|
||||
});
|
||||
} else {
|
||||
await signIn("authelia");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="group rounded-3xl p-1 transition-colors dark:hover:bg-dracula-bg-light"
|
||||
>
|
||||
<UserCircleIcon
|
||||
className={`h-8 w-auto transition-colors ${
|
||||
session?.user
|
||||
? "dark:stroke-dracula-red dark:group-hover:stroke-dracula-green"
|
||||
: "dark:stroke-dracula-cyan dark:group-hover:stroke-dracula-orange"
|
||||
}`}
|
||||
/>
|
||||
<span className="sr-only">{session?.user ? "Log out" : "Log in"}</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
187
src/app/_components/cv.tsx
Normal file
187
src/app/_components/cv.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
|
||||
import type React from "react";
|
||||
|
||||
type ExperienceContent = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
title: string;
|
||||
tech: string;
|
||||
company: string;
|
||||
content: string | React.JSX.Element;
|
||||
};
|
||||
|
||||
function PrintBreak({ count }: { count?: number }): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count ?? 1 }).map(() => (
|
||||
<br key={"break"} className="hidden print:block" />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const content: ExperienceContent[] = [
|
||||
{
|
||||
company: "Tes",
|
||||
endDate: "Present",
|
||||
startDate: "Feb 2023",
|
||||
tech: "TS/NodeJS/React/DotNet/AWS/K8s/GitOps",
|
||||
title: "Technical Lead",
|
||||
content: (
|
||||
<>
|
||||
As a technical lead, my role moved mostly into communicating with the
|
||||
wider business to ensure my team has clear, achievable objectives, then
|
||||
helping them release those objectives. I have been particularly involved
|
||||
with cross-team collaboration to continue pushing improvements to our
|
||||
development process, being part of frontend and backend guilds as well
|
||||
as having a constant input into our service architecture to set
|
||||
development-wide architectural decisions. During this time, I have
|
||||
worked in multiple tech stacks, swiftly becoming proficient with
|
||||
frameworks and languages in order to upskill my team.
|
||||
<br />
|
||||
Projects I have led include: rebuilding of some of the most used pages
|
||||
we have, releasing new web apps to millions of monthly users which
|
||||
included complex searching with filters and via a map, and user specific
|
||||
context while retaining high SEO scores and high levels of accessibility
|
||||
compliance; creating a new email workflow, that can utilizes multiple
|
||||
accounts and services to protect reputation; and creating a genetic
|
||||
algorithm built to scale with which users can track their progress when
|
||||
crunching complex sets of data.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
company: "Tes",
|
||||
endDate: "Feb 2023",
|
||||
startDate: "Jun 2022",
|
||||
tech: "TS/JS/NodeJS/React",
|
||||
title: "Senior Developer",
|
||||
content:
|
||||
"Changing fields into web development, I utilised my previous knowledge to pivot quickly into the technologies needed for full stack development. I have since worked on and improved many products across multiple teams, while using my experience to provide individual support within my team. I have also created internal initiatives to improve our developer experience as well as getting involved in architecture discussions to keep pushing our development forwards.",
|
||||
},
|
||||
{
|
||||
company: "Live 5",
|
||||
endDate: "Jun 2022",
|
||||
startDate: "Oct 2019",
|
||||
tech: "TS/JS/WebGL/NodeJS",
|
||||
title: "Development Manager",
|
||||
content: (
|
||||
<>
|
||||
As development manager, I oversaw all of the developers at Live 5 and
|
||||
had a responsibility to oversee production of over 20 games a year from
|
||||
my teams. I kept each stage of game development on track to meet both
|
||||
internal and external deadlines, able to work with my teams to either
|
||||
change the scope of the project or move developers to get the games back
|
||||
on target. <PrintBreak count={4} /> By implementing a proper code review
|
||||
process, frequent stand ups and additional tooling for developers, qa
|
||||
and artists, we produced far more complex games in less time with fewer
|
||||
bugs. In addition, I mentored both junior and senior members of my team
|
||||
to develop their technical skills, knowledge and soft skills.
|
||||
<br />
|
||||
While managing the team was my foremost responsibility, I was still
|
||||
heavily involved with development. I tackled any particularly difficult
|
||||
coding problems for the team and architecture large-scale changes within
|
||||
the codebase. For example, I integrated new business vital services and
|
||||
rebuilt our base renderer and loading core in TypeScript. One of the
|
||||
more interesting projects I directed was to rebuild our backend,
|
||||
focusing on providing local and remote interfaces to the data generation
|
||||
that allowed for faster development of more reliable game backends. The
|
||||
deployment process was also rebuilt to allow deploying into AWS for
|
||||
browser game access, as a package for a separate serverless game build
|
||||
and to run a statistical analysis on a bare metal local kubernetes
|
||||
cluster which I also administered.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
company: "Live 5",
|
||||
endDate: "Oct 2019",
|
||||
startDate: "Sept 2018",
|
||||
tech: "TS/JS/WebGL/NodeJS",
|
||||
title: "Game Developer",
|
||||
content:
|
||||
"I was hired to continue as a C++ Developer, but soon transitioned to the mobile team due to company priorities. Despite having no prior experience with JavaScript, I quickly became proficient in the language and its ecosystem, which enabled me to promptly integrate into my new role.I started with creating games, but similar to my time at Inspired, I wrote extra scripts and improved the game libraries to assist development across the team.",
|
||||
},
|
||||
{
|
||||
company: "Inspired Gaming",
|
||||
endDate: "Sept 2018",
|
||||
startDate: "Mar 2016",
|
||||
tech: "C++/DirectX/Python",
|
||||
title: "Engine/Game Developer",
|
||||
content:
|
||||
"My initial responsibilities involved converting existing games to work on a variety of hardware, though I quickly moved up to work on building some of the more complex games and tooling myself, before going on to mentor new starters.Soon after, I advanced into the game engine development team, which explored ways of improving the development cycle and coding efficiency for other developers. We improved the libraries, build steps and used middleware such as Conan and custom VS plugins to provide prebuilt binaries and improve cohesion and standards across the teams.",
|
||||
},
|
||||
];
|
||||
|
||||
function Experience({
|
||||
content,
|
||||
}: { content: ExperienceContent }): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-row gap-4 border-b-2 border-b-dracula-bg-light last:border-b-0 dark:border-b-dracula-orange">
|
||||
<div className="w-20 justify-center text-center">
|
||||
{content.endDate}
|
||||
<br />
|
||||
-
|
||||
<br />
|
||||
{content.startDate}
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-2 flex w-full flex-row border-dracula-bg-light border-b-[1px] pb-1">
|
||||
<div className="self-start text-left">{content.title}</div>
|
||||
<div className="flex-grow self-start text-right">{content.tech}</div>
|
||||
<div className="ml-3 w-20 border-dracula-bg-light border-l-[1px] pr-2 text-right">
|
||||
{content.company}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pr-2 pb-2 text-justify">{content.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO
|
||||
export default function Cv(): React.JSX.Element {
|
||||
return (
|
||||
<div className="mx-auto w-[20cm] dark:text-white print:pt-[0.5cm]">
|
||||
<div className="flex flex-col justify-center">
|
||||
<h1 className="bg-dracula-bg-light py-1 text-center font-medium text-2xl text-white uppercase">
|
||||
Joe Lewis Monk
|
||||
</h1>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="grid grid-cols-3 border-dracula-bg-light border-b-2 pb-2">
|
||||
<span className="border-dracula-bg-light border-r-[1px] text-left">
|
||||
joemonk.co.uk
|
||||
</span>
|
||||
<span className="border-dracula-bg-light border-x-[1px] text-center">
|
||||
07757 017587
|
||||
</span>
|
||||
<span className="border-dracula-bg-light border-l-[1px] text-right">
|
||||
joemonk@hotmail.co.uk
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-justify">
|
||||
As a highly motivated and adaptive developer, my enthusiasm for
|
||||
learning new technologies, along with years of rapid game and web
|
||||
development, has driven my proficiency with many languages and
|
||||
tools. This allows me to be flexible when tackling problems. Over
|
||||
the last few years I have enjoyed expanding my role to include
|
||||
management of multiple teams, and have picked up new tech stacks
|
||||
while moving between roles.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 bg-dracula-bg-light px-2 py-1 text-white">
|
||||
<PaperAirplaneIcon className="my-[2px] h-5" />
|
||||
<h2 className="font-medium">Experience</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{content.map((expContent) => (
|
||||
<Experience
|
||||
content={expContent}
|
||||
key={`${expContent.company}_${expContent.title}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/app/_components/footer.tsx
Normal file
12
src/app/_components/footer.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
// TODO
|
||||
export default function NavBar(): React.JSX.Element {
|
||||
return (
|
||||
<footer className="border-t-2 dark:border-dracula-purple dark:bg-dracula-bg-darker">
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="relative flex h-12 flex-row-reverse items-center justify-between">
|
||||
<span className="select-none dark:text-white">© Joe Monk 2024</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
211
src/app/_components/lightbox.tsx
Normal file
211
src/app/_components/lightbox.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import YARL, {
|
||||
isImageFitCover,
|
||||
isImageSlide,
|
||||
useLightboxProps,
|
||||
useLightboxState,
|
||||
} from "yet-another-react-lightbox";
|
||||
import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
import Thumbnails from "yet-another-react-lightbox/plugins/thumbnails";
|
||||
import Zoom from "yet-another-react-lightbox/plugins/zoom";
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import "yet-another-react-lightbox/plugins/thumbnails.css";
|
||||
import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import { api, type RouterOutputs } from "@/trpc/react";
|
||||
|
||||
type ImageData = RouterOutputs["photos"]["list"]["data"][number];
|
||||
|
||||
function NextJsImage({
|
||||
slide,
|
||||
offset,
|
||||
rect,
|
||||
unoptimized = false,
|
||||
}: {
|
||||
slide: ImageData;
|
||||
offset: number;
|
||||
rect: { width: number; height: number };
|
||||
unoptimized: boolean;
|
||||
}): React.JSX.Element {
|
||||
const {
|
||||
on: { click },
|
||||
carousel: { imageFit },
|
||||
} = useLightboxProps();
|
||||
|
||||
const { currentIndex } = useLightboxState();
|
||||
|
||||
const cover = isImageSlide(slide) && isImageFitCover(slide, imageFit);
|
||||
|
||||
const width = !cover
|
||||
? Math.round(
|
||||
Math.min(rect.width, (rect.height / slide.height) * slide.width),
|
||||
)
|
||||
: rect.width;
|
||||
|
||||
const height = !cover
|
||||
? Math.round(
|
||||
Math.min(rect.height, (rect.width / slide.width) * slide.height),
|
||||
)
|
||||
: rect.height;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width, height }}>
|
||||
<Image
|
||||
fill
|
||||
alt=""
|
||||
src={slide}
|
||||
loading="eager"
|
||||
unoptimized={unoptimized}
|
||||
draggable={false}
|
||||
blurDataURL={slide.blur}
|
||||
placeholder="blur"
|
||||
style={{
|
||||
objectFit: cover ? "cover" : "contain",
|
||||
cursor: click ? "pointer" : undefined,
|
||||
}}
|
||||
sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`}
|
||||
onClick={
|
||||
offset === 0
|
||||
? (): void => click?.({ index: currentIndex })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Lightbox({
|
||||
imageData,
|
||||
children,
|
||||
}: {
|
||||
imageData: ImageData[];
|
||||
children: React.JSX.Element[];
|
||||
}): React.JSX.Element {
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="mx-auto">
|
||||
<div className="flex flex-row flex-wrap justify-center">
|
||||
{children.map((image, index) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={"lightbox_img"}
|
||||
onClick={() => {
|
||||
setActive(index);
|
||||
}}
|
||||
>
|
||||
<div className="relative">{image}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<YARL
|
||||
open={typeof active === "number"}
|
||||
close={() => setActive(null)}
|
||||
index={active ?? undefined}
|
||||
slides={imageData}
|
||||
render={{
|
||||
// @ts-expect-error - Todo
|
||||
slide: (args) => NextJsImage({ ...args, unoptimized: true }),
|
||||
// @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type
|
||||
thumbnail: NextJsImage,
|
||||
}}
|
||||
plugins={[Thumbnails, Zoom, Captions]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormElements extends HTMLFormControlsCollection {
|
||||
src: HTMLInputElement;
|
||||
}
|
||||
interface UsernameFormElement extends HTMLFormElement {
|
||||
readonly elements: FormElements;
|
||||
}
|
||||
|
||||
// TODO
|
||||
export default function FilteredLightbox(props: {
|
||||
imageData: ImageData[];
|
||||
children: React.JSX.Element[];
|
||||
}): React.JSX.Element {
|
||||
//const [imageData, setImageData] = useState(props.imageData);
|
||||
const [imageData] = useState(props.imageData);
|
||||
const photoQuery = api.photos.list.useInfiniteQuery(
|
||||
{
|
||||
limit: 1,
|
||||
},
|
||||
{
|
||||
initialData: {
|
||||
pages: [
|
||||
{
|
||||
data: props.imageData,
|
||||
next: props.imageData.length,
|
||||
},
|
||||
],
|
||||
pageParams: [0],
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.next,
|
||||
},
|
||||
);
|
||||
|
||||
const refreshQuery = api.photos.update.useQuery(undefined, {
|
||||
enabled: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
function handleSubmit(event: React.FormEvent<UsernameFormElement>): void {
|
||||
event.preventDefault();
|
||||
// const imageData = props.imageData;
|
||||
// setImageData(
|
||||
// imageData.filter(
|
||||
// (data) => data.src === event.currentTarget.elements.src.value
|
||||
// )
|
||||
// );
|
||||
void photoQuery.fetchNextPage();
|
||||
}
|
||||
|
||||
const children = photoQuery.data.pages
|
||||
.flatMap((data) => data.data)
|
||||
.map((data) => (
|
||||
<Image
|
||||
key={data.src}
|
||||
alt={data.src}
|
||||
src={data.src}
|
||||
className="h-60 w-80 object-contain"
|
||||
sizes="100vw"
|
||||
loading="lazy"
|
||||
width={data.width}
|
||||
height={data.height}
|
||||
blurDataURL={data.blur}
|
||||
placeholder="blur"
|
||||
/>
|
||||
))
|
||||
.filter((data) => !!data);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="src">Src:</label>
|
||||
<input id="src" type="text" />
|
||||
</div>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void refreshQuery.refetch();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
{refreshQuery.data ? JSON.stringify(refreshQuery.data) : "\nNot"}
|
||||
{refreshQuery.error ? JSON.stringify(refreshQuery.error) : "\nNo Error"}
|
||||
<Lightbox imageData={imageData}>{...children}</Lightbox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
src/app/_components/navbar-client.tsx
Normal file
118
src/app/_components/navbar-client.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
import {
|
||||
Bars3Icon,
|
||||
HomeModernIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
AnimatePresence,
|
||||
LazyMotion,
|
||||
domAnimation,
|
||||
motion,
|
||||
} from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import ThemeSwitcher from "./theme-switcher";
|
||||
|
||||
type NavBarClientProps = {
|
||||
LogIn: React.JSX.Element;
|
||||
navigation: {
|
||||
name: string;
|
||||
href: string;
|
||||
current: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
// TODO
|
||||
export default function NavBarClient({
|
||||
LogIn,
|
||||
navigation,
|
||||
}: NavBarClientProps): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeNavigation = useMemo((): typeof navigation => {
|
||||
const nav = structuredClone(navigation);
|
||||
|
||||
const current = nav.find((nav) => nav.href === pathname);
|
||||
if (current) {
|
||||
current.current = true;
|
||||
}
|
||||
return nav;
|
||||
}, [pathname, navigation]);
|
||||
|
||||
return (
|
||||
<nav className="border-accent border-b-2">
|
||||
<LazyMotion features={domAnimation}>
|
||||
<div className="mx-auto max-w-5xl px-4">
|
||||
<div className="relative flex h-16 items-center justify-between">
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
className="btn rounded-sm border-2 border-primary/75 p-1 transition-colors duration-100 sm:hidden"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{open ? (
|
||||
<XMarkIcon className="h-8 w-auto rounded-sm" />
|
||||
) : (
|
||||
<Bars3Icon className="h-8 w-auto rounded-sm" />
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
className="btn hidden items-center rounded border-2 border-primary/75 p-1 transition-colors hover:bg-primary/25 sm:flex"
|
||||
href="/"
|
||||
>
|
||||
<HomeModernIcon className="h-8 w-auto rounded-sm" />
|
||||
</Link>
|
||||
<div className="ml-12 hidden gap-4 sm:flex">
|
||||
{activeNavigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`btn min-w-20 rounded-lg rounded-b-none border-transparent border-b-2 px-3 pt-2.5 pb-1 text-center text-lg outline-0 hover:border-primary hover:bg-primary/25 ${
|
||||
item.current ? "border-b-accent/75" : ""
|
||||
}`}
|
||||
aria-current={item.current ? "page" : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<ThemeSwitcher />
|
||||
{LogIn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
transition={{ duration: 0.15, ease: "linear" }}
|
||||
exit={{ height: 0 }}
|
||||
className="overflow-hidden sm:hidden"
|
||||
>
|
||||
<div className="flex flex-col space-y-1 py-1">
|
||||
{activeNavigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`btn border-transparent border-l-4 px-4 py-2 transition-colors duration-100 hover:border-primary hover:bg-primary/25 ${
|
||||
item.current ? "" : "border-primary"
|
||||
}`}
|
||||
aria-current={item.current ? "page" : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</LazyMotion>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
24
src/app/_components/navbar.tsx
Normal file
24
src/app/_components/navbar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/server/auth";
|
||||
import LogIn from "./auth/login";
|
||||
import NavBarClient from "./navbar-client";
|
||||
|
||||
const defaultNavigation = [
|
||||
{ name: "Posts", href: "/posts", current: false },
|
||||
{ name: "Photos", href: "/photos", current: false },
|
||||
{ name: "CV", href: "/cv", current: false },
|
||||
];
|
||||
|
||||
const authedNavigation = [{ name: "Manage", href: "/manage", current: false }];
|
||||
|
||||
export default async function NavBar(): Promise<React.JSX.Element> {
|
||||
const session = await auth();
|
||||
let nav = structuredClone(defaultNavigation);
|
||||
|
||||
if (session?.user) {
|
||||
nav = nav.concat(structuredClone(authedNavigation));
|
||||
}
|
||||
|
||||
return <NavBarClient LogIn={<LogIn />} navigation={nav} />;
|
||||
}
|
||||
35
src/app/_components/post-header.tsx
Normal file
35
src/app/_components/post-header.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
type postMetadata = {
|
||||
title: string;
|
||||
date: string;
|
||||
coverImage: string;
|
||||
blurb: string;
|
||||
shortBlurb: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
type PostHeaderProps = {
|
||||
metadata: postMetadata;
|
||||
};
|
||||
|
||||
// TODO
|
||||
export default function PostHeader({
|
||||
metadata,
|
||||
}: PostHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<h1>{metadata.title}</h1>
|
||||
<div className="mb-2">{metadata.date}</div>
|
||||
<div className="mb-6">
|
||||
{metadata.tags.map((tag) => {
|
||||
return (
|
||||
<>
|
||||
<span className="me-2 select-none rounded border border-dracula-pink px-2.5 py-1 text-sm dark:bg-dracula-bg-darker dark:text-dracula-pink">
|
||||
{tag}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
src/app/_components/theme-switcher.tsx
Normal file
26
src/app/_components/theme-switcher.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
|
||||
import type React from "react";
|
||||
|
||||
export default function ThemeSwitcher(): React.JSX.Element {
|
||||
const toggleTheme = (): void => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
if (currentTheme === 'light') {
|
||||
localStorage.theme = 'dark';
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
localStorage.theme = 'light';
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="m-1 h-8 w-8 rounded-full" onClick={toggleTheme}>
|
||||
<MoonIcon className="block dark:hidden" />
|
||||
<SunIcon className="hidden dark:block" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,3 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { handlers } from "@/lib/auth";
|
||||
import { handlers } from "@/server/auth";
|
||||
|
||||
const reqWithTrustedOrigin = (req: NextRequest): NextRequest => {
|
||||
const proto = req.headers.get('x-forwarded-proto');
|
||||
const host = req.headers.get('x-forwarded-host');
|
||||
if (!proto || !host) {
|
||||
console.warn("Missing x-forwarded-proto or x-forwarded-host headers.");
|
||||
return req;
|
||||
}
|
||||
const envOrigin = `${proto}://${host}`;
|
||||
const { href, origin } = req.nextUrl;
|
||||
return new NextRequest(href.replace(origin, envOrigin), req);
|
||||
};
|
||||
|
||||
export const GET = (req: NextRequest): Promise<Response> => {
|
||||
return handlers.GET(reqWithTrustedOrigin(req));
|
||||
};
|
||||
|
||||
export const POST = (req: NextRequest): Promise<Response> => {
|
||||
return handlers.POST(reqWithTrustedOrigin(req));
|
||||
};
|
||||
export const { GET, POST } = handlers;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET(): Response {
|
||||
return NextResponse.json({ status: 200 }, { status: 200 });
|
||||
}
|
||||
return NextResponse.json({ status: 200 }, { status: 200 });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import { createTRPCContext } from '@/trpc/init';
|
||||
import { appRouter } from '@/trpc/routers/_app';
|
||||
const handler = (req: Request): Promise<Response> =>
|
||||
fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
});
|
||||
export { handler as GET, handler as POST };
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { createTRPCContext } from "@/server/api/trpc";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a HTTP request (e.g. when you make requests from Client Components).
|
||||
*/
|
||||
const createContext = async (req: NextRequest) => {
|
||||
return createTRPCContext({
|
||||
headers: req.headers,
|
||||
});
|
||||
};
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createContext(req),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,9 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,49 @@
|
||||
import "reflect-metadata";
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
});
|
||||
import type { Metadata } from "next";
|
||||
import { Fira_Sans } from "next/font/google";
|
||||
|
||||
import { TRPCReactProvider } from "@/trpc/react";
|
||||
import { HydrateClient } from "@/trpc/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Joe Monk",
|
||||
description: "A portfolio page showing some of the things I've done",
|
||||
title: "Joe Monk",
|
||||
description: "A portfolio page showing some of the things I've done",
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
const fira = Fira_Sans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-fira-sans",
|
||||
weight: ["400", "500", "600"]
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>): React.JSX.Element {
|
||||
return (
|
||||
// Use suppress hydration warnings to add the dark theme class on client
|
||||
<html className={`${inter.variable} font-sans`} lang="en" suppressHydrationWarning>
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en" className={`${fira.variable}`} suppressHydrationWarning>
|
||||
<head>
|
||||
<script id="SetTheme"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Doing some pre-render theming
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
if (localStorage.theme !== 'dark' || (!('theme' in localStorage) && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
if ('theme' in localStorage) {
|
||||
document.documentElement.setAttribute('data-theme', localStorage.theme)
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
}
|
||||
}`,
|
||||
}}>
|
||||
</script>
|
||||
}}/>
|
||||
</head>
|
||||
<body className="min-h-screen flex flex-col bg-dracula-bg-lightest dark:bg-dracula-bg print:white max-h-screen">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<TRPCReactProvider>
|
||||
<HydrateClient>{children}</HydrateClient>
|
||||
</TRPCReactProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user