Merge pull request 'Migrated websites ui to nextui using the newest Next.js version' (#1) from nextui into main
continuous-integration/drone/push Build is passing Details

Reviewed-on: #1
main
Guus van Meerveld 9 months ago
commit a789cd33fb

@ -2,9 +2,12 @@
!.env
!next.config.js
!tailwind.config.js
!postcss.config.js
!tsconfig.json
!package.json
!yarn.lock
!public
!prisma
!src
!data

@ -8,10 +8,10 @@ platform:
steps:
- name: Test the newest commit
image: node:16-alpine
image: node:lts-alpine
volumes:
- name: cache
path: /drone/src/node_modules
- name: cache
path: /drone/src/node_modules
commands:
- yarn install
- yarn run prisma:generate

@ -1,2 +1,2 @@
DATABASE_URL=postgresql://portfolio:portfolio@localhost:5432/portfolio?schema=public
LANDING_JSON_LOCATION=./landing.json
DATA_DIR=./data

@ -21,6 +21,8 @@ RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/postcss.config.js ./
COPY --from=builder /app/tailwind.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@ -0,0 +1,49 @@
{
"header": {
"fullName": "Guus van Meerveld",
"name": "guus",
"description": "AI student at Radboud University. Creating software as a hobby.",
"contact": {
"email": "contact@guusvanmeerveld.dev",
"git": "https://github.com/Guusvanmeerveld",
"linkedin": "https://linkedin.com/in/guus-van-meerveld-038357210"
}
},
"projects": [
{
"name": "Dust-Mail",
"avatarUrl": "https://avatars.githubusercontent.com/u/130915639?s=200&v=4",
"description": "Dust-Mail is a free and open source project that aims to replace all desktop and web email clients by providing a fast and simple experience.",
"url": "https://github.com/Dust-Mail/"
},
{
"name": "Argo-Client",
"avatarUrl": "https://avatars.githubusercontent.com/u/71986232?s=200&v=4",
"description": "Argo is a modern client for Magister 6 that is available for Android and IOS.",
"url": "https://argo-magister.nl"
},
{
"name": "MaterialTube",
"avatarUrl": "https://raw.githubusercontent.com/Guusvanmeerveld/MaterialTube/master/src/svg/logo.svg",
"description": "MaterialTube is a beautiful and elegant web client for Invidious servers, built using Next.js and MUI.",
"url": "https://github.com/Guusvanmeerveld/MaterialTube"
}
],
"footer": {
"columns": [
{
"title": "Built with",
"links": [
{ "url": "https://nextjs.org/", "text": "Next.js" },
{ "url": "https://nextui.org/", "text": "NextUI" }
]
},
{
"title": "Social",
"links": [
{ "url": "https://github.com/Guusvanmeerveld", "text": "Github" }
]
}
]
}
}

@ -1,17 +1,15 @@
version: "3"
services:
# app:
# build: .
# container_name: portfolio
# environment:
# NEXT_PUBLIC_GITEA_USERNAME: "Guusvanmeerveld"
# NEXT_PUBLIC_GITEA_SERVER: "git.guusvanmeerveld.dev"
# SESSION_PASSWORD: "jsdlakfjad;slkfjepoweaur3290r830-q998-039jaklfjdkl"
# NEXT_PUBLIC_ALLOW_REGISTRATION: true
# DATABASE_URL: "postgres://portfolio:portfolio@db:5432/portfolio"
# ports:
# - 3000:3000
app:
build: .
container_name: portfolio
volumes:
- ./data:/app/data
environment:
DATABASE_URL: "postgres://portfolio:portfolio@db:5432/portfolio"
ports:
- 3000:3000
db:
image: postgres:14

@ -1,12 +0,0 @@
{
"owner": {
"fullName": "Guus van Meerveld",
"name": "guus",
"description": "AI student at Radboud University. Creating software as a hobby.",
"contact": {
"email": "contact@guusvanmeerveld.dev",
"git": "https://github.com/Guusvanmeerveld",
"linkedin": "https://linkedin.com/in/guus-van-meerveld-038357210"
}
}
}

@ -16,22 +16,24 @@
"full-test": "yarn test-build && yarn lint && yarn stylelint"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.15.11",
"@nextui-org/react": "^2.2.10",
"@prisma/client": "4.10.1",
"@tanstack/react-query": "^4.24.9",
"axios": "^1.1.2",
"bcrypt": "^5.1.0",
"framer-motion": "^11.0.8",
"fs-extra": "^11.2.0",
"iron-session": "^6.3.1",
"next": "^12.1.0",
"next-seo": "^4.24.0",
"next": "^14.1.1",
"next-themes": "^0.2.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.0.1",
"timeago.js": "^4.0.2",
"zod": "^3.20.6"
"zod": "^3.20.6",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.0.0",

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

@ -0,0 +1,82 @@
"use client";
import { Button } from "@nextui-org/button";
import { Image } from "@nextui-org/image";
import { Spacer } from "@nextui-org/react";
import { Tooltip } from "@nextui-org/tooltip";
import { Component } from "@typings/component";
import Link from "next/link";
import { useMemo } from "react";
import { FiGithub, FiMail, FiLinkedin } from "react-icons/fi";
import HeaderProps from "@models/header";
export const Header: Component<{ data: HeaderProps; avatar: string }> = ({
data,
avatar
}) => {
const socials = useMemo(
() => [
{
link: `mailto:${data.contact.email}`,
name: "Email address",
icon: <FiMail />
},
{
link: data.contact.git,
name: "Github",
icon: <FiGithub />
},
{
link: data.contact.linkedin,
name: "LinkedIn",
icon: <FiLinkedin />
}
],
[data.contact]
);
return (
<div className="container mx-auto flex items-center min-h-screen">
<div className="flex items-center">
<Image
isBlurred
src={avatar}
width={300}
alt={`A picture of ${data.fullName}`}
/>
<Spacer x={8} />
<div>
<h1 className="text-4xl">{data.fullName}</h1>
<Spacer y={4} />
<h2 className="text-2xl">{data.description}</h2>
<Spacer y={4} />
{socials.map((social) => (
<Tooltip
key={social.name.toLowerCase()}
showArrow
content={social.name}
>
<Button
href={social.link}
as={Link}
className="text-2xl mr-4"
color="primary"
variant="shadow"
isIconOnly
aria-label={social.name}
>
{social.icon}
</Button>
</Tooltip>
))}
</div>
</div>
</div>
);
};

@ -0,0 +1,55 @@
"use client";
import { Card, CardHeader, CardBody, CardFooter } from "@nextui-org/card";
import { Divider } from "@nextui-org/divider";
import { Image } from "@nextui-org/image";
import { Link } from "@nextui-org/link";
import { Spacer } from "@nextui-org/spacer";
import { Component } from "@typings/component";
import ProjectProps from "@models/project";
export const Projects: Component<{ data: ProjectProps[] }> = ({ data }) => {
return (
<>
<div className="container mx-auto">
<h1 className="text-4xl text-center mb-8">Projects</h1>
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{data.map((project) => {
const url = new URL(project.url);
return (
<Card key={project.name}>
<CardHeader className="flex gap-3">
<Image
alt={`${project.name} logo`}
height={40}
radius="sm"
src={project.avatarUrl}
width={40}
/>
<div className="flex flex-col">
<p className="text-md">{project.name}</p>
<p className="text-small text-default-500">{url.host}</p>
</div>
</CardHeader>
<Divider />
<CardBody>
<p>{project.description}</p>
</CardBody>
<Divider />
<CardFooter>
<Link isExternal showAnchorIcon href={project.url}>
Visit the project.
</Link>
</CardFooter>
</Card>
);
})}
</div>
</div>
<Spacer y={24} />
</>
);
};

@ -0,0 +1,22 @@
import { Footer } from "../Footer";
import { Header } from "./Header";
import { Projects } from "./Projects";
import { dataDirLocation } from "@utils/constants";
import { readAvatarFile, readLandingJson } from "@utils/landing";
export default async function Page() {
// Any error will get handled by the `error.tsx` file.
const landing = await readLandingJson(dataDirLocation);
const avatar = await readAvatarFile(dataDirLocation);
return (
<>
<Header data={landing.header} avatar={avatar} />
<Projects data={landing.projects} />
<Footer data={landing.footer} />
</>
);
}
export const revalidate = 3600;

@ -0,0 +1,45 @@
"use client";
import { Divider } from "@nextui-org/divider";
import { Link } from "@nextui-org/link";
import { Component } from "@typings/component";
import { ThemeSwitcher } from "./ThemeSwitcher";
import FooterProps from "@models/footer";
export const Footer: Component<{ data: FooterProps }> = ({ data }) => {
return (
<div className="container mx-auto grid grid-flow-col justify-stretch my-4">
<div className="mx-4">
<h1 className="text-xl">
Created with{" "}
<span role="img" aria-label="Red Heart">
&#x2764;&#xfe0f;
</span>{" "}
by Guus van Meerveld
</h1>
<Divider className="my-4" />
<ThemeSwitcher />
</div>
{data.columns.map((column) => (
<div className="mx-4" key={column.title.toLowerCase()}>
<h1 className="text-xl">{column.title}</h1>
<Divider className="mt-4" />
{column.links.map((link) => (
<div className="my-4" key={link.url}>
<Link className="text-default-500" href={link.url}>
{link.text}
</Link>
</div>
))}
</div>
))}
</div>
);
};

@ -0,0 +1,48 @@
"use client";
import {
Button,
Link,
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem
} from "@nextui-org/react";
import { Component } from "@typings/component";
export const Nav: Component = () => {
return (
<Navbar position="sticky">
<NavbarBrand>
<p className="font-bold text-inherit">ACME</p>
</NavbarBrand>
<NavbarContent className="hidden sm:flex gap-4" justify="center">
<NavbarItem>
<Link color="foreground" href="#">
Features
</Link>
</NavbarItem>
<NavbarItem isActive>
<Link href="#" aria-current="page">
Customers
</Link>
</NavbarItem>
<NavbarItem>
<Link color="foreground" href="#">
Integrations
</Link>
</NavbarItem>
</NavbarContent>
<NavbarContent justify="end">
<NavbarItem className="hidden lg:flex">
<Link href="#">Login</Link>
</NavbarItem>
<NavbarItem>
<Button as={Link} color="primary" href="#" variant="flat">
Sign Up
</Button>
</NavbarItem>
</NavbarContent>
</Navbar>
);
};

@ -0,0 +1,32 @@
"use client";
import { Switch } from "@nextui-org/react";
import { Component } from "@typings/component";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { FiMoon, FiSun } from "react-icons/fi";
export const ThemeSwitcher: Component = () => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<Switch
defaultSelected
size="lg"
color="primary"
onValueChange={(value) => {
value ? setTheme("dark") : setTheme("light");
}}
startContent={<FiSun />}
endContent={<FiMoon />}
/>
);
};

@ -0,0 +1,28 @@
"use client";
import { Link } from "@nextui-org/react";
import { ErrorPage } from "@typings/errorPage";
import { useEffect } from "react";
const MainErrorPage: ErrorPage = ({ error, reset }) => {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="container mx-auto flex items-center justify-center min-h-screen text-center">
<div>
<p className="text-3xl">Something went loading the page!</p>
<p className="text-xl">{error.toString()}</p>
<div>
<Link href="#" onClick={() => reset()}>
Try again
</Link>
</div>
</div>
</div>
);
};
export default MainErrorPage;

@ -0,0 +1,30 @@
import { Metadata } from "next";
import { Footer } from "./Footer";
import { Providers } from "./providers";
import "@styles/global.scss";
export default function RootLayout({
children
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
export const metadata: Metadata = {
title: {
default: "Portfolio",
template: "%s | Portfolio"
},
description: "Guus van Meerveld's portfolio",
applicationName: "Portfolio",
manifest: "/manifest.json"
};

@ -0,0 +1,15 @@
"use client";
import { NextUIProvider } from "@nextui-org/react";
import { Component } from "@typings/component";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export const Providers: Component = ({ children }) => {
return (
<NextUIProvider>
<NextThemesProvider attribute="class" defaultTheme="dark">
{children}
</NextThemesProvider>
</NextUIProvider>
);
};

@ -1,13 +0,0 @@
import styles from "./emptyPage.module.scss";
import { FC } from "react";
const EmptyPage: FC = ({ children }) => {
return (
<div className={styles.body}>
<div className="container">{children}</div>
</div>
);
};
export default EmptyPage;

@ -1,69 +0,0 @@
import Link from "next/link";
import styles from "./footer.module.scss";
import { FC } from "react";
import multipleClassNames from "@utils/multipleClassNames";
const Footer: FC = () => {
return (
<footer className={multipleClassNames("container", styles.main)}>
<div className="columns">
<div className="column col-8 col-md-12 col-mx-auto">
<div className="columns mb-2">
<div className="col col-12">
<h3>Guus van Meerveld</h3>
</div>
<div className="col col-12">
&middot;
<Link href="https://twitter.com/Guusvanmeerveld">
<a className="mx-2">Twitter</a>
</Link>
&middot;
<Link href="https://ko-fi.com/Guusvanmeerveld">
<a className="mx-2">Ko-fi</a>
</Link>
&middot;
<Link href="https://youtube.com/channel/UCYuqpoMay5SezCBrA_HKVWQ">
<a className="mx-2">Youtube</a>
</Link>
</div>
<div className="col col-12">
<span>Pages: </span>
<Link href={{ pathname: "/" }}>
<a className="mr-2">Home</a>
</Link>
&middot;
<Link href={{ pathname: "/blog" }}>
<a className="mx-2">Blog</a>
</Link>
&middot;
<Link href={{ pathname: "/link" }}>
<a className="mx-2">Links</a>
</Link>
&middot;
<Link href={{ pathname: "/login" }}>
<a className="mx-2">Login</a>
</Link>
</div>
<div className="col col-12">
<p>
Built with{" "}
<span role="img" aria-label="heart emoji">
</span>{" "}
by Guus van Meerveld, using{" "}
<Link href="https://picturepan2.github.io/spectre">
<a>Spectre.css</a>
</Link>
</p>
</div>
</div>
</div>
</div>
</footer>
);
};
export default Footer;

@ -1,16 +0,0 @@
import { FC } from "react";
import Footer from "@components/Footer";
import ThemeChanger from "@components/ThemeChanger";
const Layout: FC = ({ children }) => {
return (
<>
<ThemeChanger />
{children}
<Footer />
</>
);
};
export default Layout;

@ -1,71 +0,0 @@
import Link from "next/link";
import z from "zod";
import styles from "./linkComponent.module.scss";
import { FC, useCallback, useState } from "react";
import axios from "axios";
import { Link as LinkType } from "@prisma/client";
import { LinkId } from "@models/link";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
import useUser from "@utils/hooks/useUser";
import multipleClassNames from "@utils/multipleClassNames";
const LinkComponent: FC<{ link: LinkType }> = ({ link }) => {
const user = useUser();
const [error, setError] = useState<string | null>(null);
const [deleted, setDeleted] = useState(false);
const deleteLink = useCallback(async (id) => {
const parseUserInputResult = LinkId.safeParse(id);
if (!parseUserInputResult.success) {
setError(parseUserInputResult.error.message);
return;
}
const response = await axios
.delete("/api/link/delete", { params: { id } })
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (!response.ok) {
setError(JSON.stringify(response.error));
return;
}
setDeleted(true);
}, []);
if (deleted) return <></>;
return (
<div className={multipleClassNames("bg-gray", "s-rounded", styles.body)}>
<div className="container">
<div className="columns">
<div className="col col-11">
<Link href={link.remoteAddress}>
<a>{link.location}</a>
</Link>
</div>
{user !== null && (
<div className="col col-1">
<Link href={`/link/edit/${link.id}`}>
<a className="mr-2">edit</a>
</Link>
<a className={styles.delete} onClick={() => deleteLink(link.id)}>
delete
</a>
</div>
)}
</div>
</div>
</div>
);
};
export default LinkComponent;

@ -1,104 +0,0 @@
import { useRouter } from "next/router";
import { FC, FormEvent, useCallback, useState } from "react";
import axios from "axios";
import { LoginCredentials } from "@models/login";
import { Response } from "@models/response";
import { parseUserInputError } from "@utils/errors";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
const LoginForm: FC = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const login = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const parseUserInputResult = LoginCredentials.safeParse({
username,
password,
rememberMe
});
if (!parseUserInputResult.success) {
setError(parseUserInputError(parseUserInputResult.error.message));
return;
}
const response: Response = await axios
.post("/api/login", parseUserInputResult.data)
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (response.ok) {
router.push("/blog");
} else {
setError(JSON.stringify(response.error));
}
},
[username, password, rememberMe, router]
);
return (
<form onSubmit={login}>
<div className="form-group">
<label className="form-label" htmlFor="email">
Email address
</label>
<input
required
className="form-input"
onChange={(e) => setUsername(e.target.value)}
name="username"
type="email"
id="email"
placeholder="mail@example.com"
/>
<label className="form-label" htmlFor="password">
Password
</label>
<input
required
className="form-input"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
minLength={8}
maxLength={128}
name="password"
type="password"
id="password"
/>
<label className="form-checkbox">
<input
checked={rememberMe}
onChange={() => setRememberMe((state) => !state)}
name="rememberMe"
type="checkbox"
/>
<i className="form-icon" /> Remember me
</label>
{error !== null && (
<div className="toast toast-error mb-2">
<button
className="btn btn-clear float-right"
onClick={() => setError(null)}
/>
{error}
</div>
)}
<input className="btn btn-primary" type="submit" value="Login" />
</div>
</form>
);
};
export default LoginForm;

@ -1,54 +0,0 @@
import Link from "next/link";
import styles from "./post.module.scss";
import { FC } from "react";
import { Post, User } from "@prisma/client";
import multipleClassNames from "@utils/multipleClassNames";
const PostsPage: FC<{
post: Post & {
author: User;
};
}> = ({ post }) => {
return (
<div className="columns mt-2">
<div
className={multipleClassNames(
"column col-4 col-md-12 col-ml-auto bg-gray",
styles.body
)}
>
<h3>{post.title}</h3>
{post.tags.map((tag) => (
<span key={tag} className="chip">
{tag}
</span>
))}
{post.content && (
<p className="mt-2 mb-0">
{post.content?.length > 300
? post.content.slice(0, 300)
: post.content}{" "}
<Link href={`/blog/${post.id}`}>Continue reading</Link>
</p>
)}
</div>
<div
className={multipleClassNames(
"column col-4 col-md-12 col-mr-auto bg-gray",
styles.body,
styles.info
)}
>
<h5>Posted on {new Date(post.createdAt).toLocaleDateString()}</h5>
<h6>By {post.author.name}</h6>
</div>
</div>
);
};
export default PostsPage;

@ -1,111 +0,0 @@
import { useRouter } from "next/router";
import { FC, FormEvent, useCallback, useState } from "react";
import axios from "axios";
import { Response } from "@models/response";
import { SignupCredentials } from "@models/signup";
import { parseUserInputError } from "@utils/errors";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
const SignupForm: FC = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [username, setUsername] = useState("");
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const signup = useCallback(
async (e: FormEvent): Promise<void> => {
e.preventDefault();
const parseUserInputResult = SignupCredentials.safeParse({
email,
password,
name: username
});
if (!parseUserInputResult.success) {
setError(parseUserInputError(parseUserInputResult.error.message));
return;
}
const response: Response = await axios
.post("/api/signup", parseUserInputResult.data)
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (response.ok) {
router.push("/blog");
} else {
setError(JSON.stringify(response.error));
}
},
[email, password, username, router]
);
return (
<form onSubmit={signup}>
<div className="form-group">
<label className="form-label" htmlFor="email">
Email address
</label>
<input
required
className="form-input"
onChange={(e) => setEmail(e.target.value)}
name="email"
type="email"
id="email"
placeholder="mail@example.com"
/>
<label className="form-label" htmlFor="password">
Password
</label>
<input
required
className="form-input"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
minLength={8}
maxLength={128}
name="password"
type="password"
id="password"
/>
<label className="form-label" htmlFor="password">
Name
</label>
<input
required
className="form-input"
placeholder="Full name"
onChange={(e) => setUsername(e.target.value)}
maxLength={32}
name="name"
type="text"
id="name"
/>
{error !== null && (
<div className="toast toast-error mt-2">
<button
className="btn btn-clear float-right"
onClick={() => setError(null)}
/>
{error}
</div>
)}
<input className="btn btn-primary mt-2" type="submit" value="Signup" />
</div>
</form>
);
};
export default SignupForm;

@ -1,15 +0,0 @@
import { FC } from "react";
const Tags: FC<{ tags: string[] }> = ({ tags }) => {
return (
<>
{tags.map((tag) => (
<span key={tag} className="chip">
{tag}
</span>
))}
</>
);
};
export default Tags;

@ -1,30 +0,0 @@
import { useTheme } from "next-themes";
import styles from "./themeChanger.module.scss";
import { FC, useEffect, useState } from "react";
const ThemeChanger: FC = () => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return <></>;
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<a
role="button"
tabIndex={0}
className={styles.main}
onClick={() => setTheme(theme == "light" ? "dark" : "light")}
>
{theme}
</a>
);
};
export default ThemeChanger;

@ -1,73 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import styles from "./user.module.scss";
import { FC } from "react";
import Owner from "@models/owner";
import multipleClassNames from "@utils/multipleClassNames";
const User: FC<{ owner: Owner }> = ({ owner }) => {
return (
<div className={styles.main}>
<div className="container">
<div className="columns">
<div
className={multipleClassNames(
"column",
"col-4",
"col-mx-auto",
styles.avatarCol
)}
>
<div className={styles.avatarContainer}>
<Image
src={owner.avatar ?? ""}
className={styles.avatar}
width={256}
height={256}
alt={`${owner.name}'s avatar`}
/>
</div>
</div>
<div className="column col-8 col-md-12 col-mx-auto">
<h1>{owner.fullName}</h1>
<h3>{owner.description}</h3>
<p>
<Link
target="_blank"
rel="noreferrer"
href={owner.contact.git}
className="btn btn-primary mr-2"
>
Git
</Link>
<Link
target="_blank"
rel="noreferrer"
href={owner.contact.linkedin}
className="btn btn-primary mr-2"
>
Git
</Link>
<Link
href={`mailto:${owner.contact.email}`}
className="btn btn-primary"
>
Contact
</Link>
</p>
</div>
</div>
</div>
</div>
);
};
export default User;

@ -1,3 +0,0 @@
.body {
padding: 10rem 0;
}

@ -1,6 +0,0 @@
$margin: 1rem;
.main {
margin-top: $margin;
margin-bottom: $margin;
}

@ -1,8 +0,0 @@
.body {
padding: 1rem;
margin-bottom: 1rem;
}
.delete {
cursor: pointer;
}

@ -1,7 +0,0 @@
.body {
padding: 1rem;
}
.info {
text-align: right;
}

@ -1,7 +0,0 @@
.card {
border: 0;
}
.main {
margin-bottom: 3rem;
}

@ -1,7 +0,0 @@
.main {
position: absolute;
top: 1rem;
right: 1rem;
cursor: pointer;
}

@ -1,17 +0,0 @@
.main {
padding-top: 10rem;
margin-bottom: 2rem;
}
.avatar {
border-radius: 5px;
}
.avatarContainer {
margin-right: 2rem;
}
.avatarCol {
display: flex;
justify-content: right;
}

@ -0,0 +1,14 @@
import z from "zod";
const FooterColumnModel = z.object({
title: z.string(),
links: z.object({ url: z.string().url(), text: z.string() }).array()
});
export const FooterPropsModel = z.object({
columns: FooterColumnModel.array()
});
export type FooterProps = z.infer<typeof FooterPropsModel>;
export default FooterProps;

@ -1,10 +1,9 @@
import z from "zod";
export const OwnerModel = z.object({
export const HeaderPropsModel = z.object({
fullName: z.string(),
name: z.string(),
description: z.string(),
avatar: z.string().optional(),
contact: z.object({
email: z.string().email(),
linkedin: z.string().url(),
@ -12,6 +11,6 @@ export const OwnerModel = z.object({
})
});
export type Owner = z.infer<typeof OwnerModel>;
export type HeaderProps = z.infer<typeof HeaderPropsModel>;
export default Owner;
export default HeaderProps;

@ -1,9 +1,13 @@
import z from "zod";
import { OwnerModel } from "./owner";
import { FooterPropsModel } from "./footer";
import { HeaderPropsModel } from "./header";
import { ProjectPropsModel } from "./project";
export const LandingModel = z.object({
owner: OwnerModel
header: HeaderPropsModel,
projects: ProjectPropsModel.array(),
footer: FooterPropsModel
});
export type Landing = z.infer<typeof LandingModel>;

@ -1,15 +0,0 @@
import z from "zod";
export const LinkId = z.number().int().positive();
export const LinkIdFromString = z.preprocess(
(a) => parseInt(z.string().parse(a)),
LinkId
);
const Link = z.object({
remoteAddress: z.string().url(),
location: z.string()
});
export default Link;

@ -1,7 +0,0 @@
import z from "zod";
export const LoginCredentials = z.object({
username: z.string().email(),
password: z.string().min(8).max(128),
rememberMe: z.boolean()
});

@ -1,8 +0,0 @@
import z from "zod";
export const Post = z.object({
title: z.string().trim(),
content: z.string().min(100).trim(),
tags: z.string().trim(),
publish: z.boolean()
});

@ -0,0 +1,12 @@
import z from "zod";
export const ProjectPropsModel = z.object({
name: z.string(),
avatarUrl: z.string().url(),
description: z.string(),
url: z.string().url()
});
export type ProjectProps = z.infer<typeof ProjectPropsModel>;
export default ProjectProps;

@ -1,27 +0,0 @@
import z from "zod";
interface Error {
ok: false;
error?: unknown;
}
export const ErrorModel: z.ZodType<Error> = z.object({
ok: z.literal(false),
error: z.unknown()
});
interface Ok {
ok: true;
data?: unknown;
}
export const OkModel: z.ZodType<Ok> = z.object({
ok: z.literal(true),
data: z.unknown()
});
export type Response = Ok | Error;
const ResponseModel: z.ZodType<Response> = ErrorModel.or(OkModel);
export default ResponseModel;

@ -1,7 +0,0 @@
import z from "zod";
export const SignupCredentials = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
name: z.string().max(32)
});

@ -1,8 +0,0 @@
import { z } from "zod";
import { SignupCredentials } from "./signup";
export const User = SignupCredentials.extend({
id: z.number(),
admin: z.boolean()
});

@ -1,9 +0,0 @@
import type { DefaultSeoProps } from "next-seo";
const SEO: DefaultSeoProps = {
titleTemplate: "%s | Guus van Meerveld",
defaultTitle: "Guus van Meerveld",
description: "Guus van Meerveld's portfolio"
};
export default SEO;

@ -1,8 +0,0 @@
.main {
height: 100vh;
margin-bottom: 1rem;
display: flex;
justify-content: center;
align-items: center;
}

@ -1,35 +0,0 @@
import { NextPage } from "next";
import { useRouter } from "next/router";
import styles from "./404.module.scss";
import multipleClassNames from "@utils/multipleClassNames";
import Layout from "@components/Layout";
const NotFound: NextPage = () => {
const router = useRouter();
return (
<Layout>
<div className={multipleClassNames("empty", styles.main)}>
<div>
<div className="empty-icon">
<i className="icon icon-stop"></i>
</div>
<p className="empty-title h5">Page not found</p>
<p className="empty-subtitle">
The page has either been deleted or moved
</p>
<div className="empty-action">
<button onClick={() => router.back()} className="btn btn-primary">
Go back
</button>
</div>
</div>
</div>
</Layout>
);
};
export default NotFound;

@ -1,25 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DefaultSeo } from "next-seo";
import { ThemeProvider } from "next-themes";
import type { AppProps } from "next/app";
import SEO from "../next-seo.config";
import "@styles/globals.scss";
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } }
});
const App = ({ Component, pageProps }: AppProps): JSX.Element => (
<>
<DefaultSeo {...SEO} />
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</ThemeProvider>
</>
);
export default App;

@ -1,3 +0,0 @@
.body {
padding: 10rem 0;
}

@ -1,118 +0,0 @@
import { NextPage } from "next";
import { NextSeo } from "next-seo";
import styles from "./admin.module.scss";
import { Post, User } from "@prisma/client";
import prisma from "@utils/prisma";
import { withSessionSsr } from "@utils/session";
import Layout from "@components/Layout";
const AdminPage: NextPage<{
user: User;
users: User[];
posts: (Post & { author: User })[];
}> = ({ user, users, posts }) => {
return (
<Layout>
<NextSeo title="Admin" />
<div className={styles.body}>
<div className="container">
<div className="columns">
<div className="col col-8 col-mx-auto">
<h3>Welcome {user.name}</h3>
</div>
<div className="col col-8 col-md-12 col-mx-auto py-2">
<h4>Users</h4>
<table className="table table-striped table-hover mb-2">
<thead>
<tr>
<th>id</th>
<th>name</th>
<th>email</th>
<th>admin</th>
<th>postCount</th>
</tr>
</thead>
<tbody>
{users.map((user, i) => (
<tr key={user.id} className={i % 2 === 0 ? "active" : ""}>
<td>{user.id}</td>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.admin ? "true" : "false"}</td>
<td>0</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="col col-8 col-md-12 col-mx-auto">
<h4>Posts</h4>
<table className="table table-striped table-hover">
<thead>
<tr>
<th>id</th>
<th>title</th>
<th>content</th>
<th>published</th>
<th>createdAt</th>
<th>tags</th>
<th>author</th>
</tr>
</thead>
<tbody>
{posts.map((post, i) => (
<tr key={post.id} className={i % 2 === 0 ? "active" : ""}>
<td>{post.id}</td>
<td>{post.title}</td>
<td>{post.content}</td>
<td>{post.published ? "true" : "false"}</td>
<td>{new Date(post.createdAt).toLocaleString()}</td>
<td>{post.tags}</td>
<td>{post.author.name}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</Layout>
);
};
export const getServerSideProps = withSessionSsr(async ({ req }) => {
const user = req.session.user;
if (user === undefined || !user.admin) return { notFound: true };
const posts = await prisma.post.findMany({
orderBy: { createdAt: "desc" },
take: 5,
include: { author: true }
});
const users = await prisma.user.findMany({
orderBy: { id: "desc" },
take: 5
});
return {
props: {
user,
users,
posts: posts.map((post) => ({
...post,
createdAt: post.createdAt.getTime(),
tags: post.tags.join(", "),
content: post.content?.slice(0, 100).concat("...")
}))
}
};
});
export default AdminPage;

@ -1,49 +0,0 @@
import { NextApiHandler } from "next";
import { Post } from "@models/post";
import { Response } from "@models/response";
import { methodNotAllowed, unauthorized } from "@utils/errors";
import prisma from "@utils/prisma";
import { withIronSession } from "@utils/session";
const handle: NextApiHandler<Response> = async (req, res) => {
if (req.method?.toUpperCase() != "POST") {
res.status(405).json(methodNotAllowed);
return;
}
const postData = Post.safeParse(req.body);
if (!postData.success) {
res.status(403).json({ ok: false, error: postData.error });
return;
}
const user = req.session.user;
if (user === undefined) {
res.status(401).json(unauthorized);
return;
}
const { publish, ...data } = postData.data;
await prisma.user
.update({
where: { id: user.id },
data: {
posts: {
create: {
...data,
published: publish,
tags: data.tags.split(" ")
}
}
}
})
.then(() => res.json({ ok: true, data: "Successfully created new post" }))
.catch((error) => res.status(500).json({ ok: false, error }));
};
export default withIronSession(handle);

@ -1,58 +0,0 @@
import { NextApiHandler } from "next";
import z from "zod";
import { LinkIdFromString } from "@models/link";
import { Response } from "@models/response";
import { methodNotAllowed, unauthorized } from "@utils/errors";
import prisma from "@utils/prisma";
import { withIronSession } from "@utils/session";
const handler: NextApiHandler<Response> = async (req, res) => {
if (req.method?.toUpperCase() !== "DELETE") {
res.status(405).json(methodNotAllowed);
return;
}
const user = req.session.user;
if (user === undefined) {
res.status(401).json(unauthorized);
return;
}
const parsePostDataResult = z
.object({
id: LinkIdFromString
})
.safeParse(req.query);
if (!parsePostDataResult.success) {
res
.status(400)
.json({ ok: false, error: parsePostDataResult.error.message });
return;
}
const link = await prisma.link.findUnique({
where: { id: parsePostDataResult.data.id },
include: { author: true }
});
if (link === null) {
res.status(400).json({ ok: false, error: "Link does not exist" });
return;
}
if (link.authorId !== user.id) {
res.status(401).json(unauthorized);
return;
}
await prisma.link
.delete({ where: { id: link.id } })
.then(() => res.json({ ok: true, data: "Successfully deleted link" }))
.catch((error) => res.status(500).json({ ok: false, error }));
};
export default withIronSession(handler);

@ -1,43 +0,0 @@
import { NextApiHandler } from "next";
import Link from "@models/link";
import { Response } from "@models/response";
import { methodNotAllowed, unauthorized } from "@utils/errors";
import prisma from "@utils/prisma";
import { withIronSession } from "@utils/session";
const handler: NextApiHandler<Response> = async (req, res) => {
if (req.method?.toUpperCase() !== "POST") {
res.status(405).json(methodNotAllowed);
return;
}
const user = req.session.user;
if (user === undefined) {
res.status(401).json(unauthorized);
return;
}
const parsedInputResult = Link.safeParse(req.body);
if (!parsedInputResult.success) {
res.status(400).json({ ok: false, error: parsedInputResult.error.message });
return;
}
await prisma.user
.update({
where: { id: user.id },
data: { links: { create: parsedInputResult.data } }
})
.then(() => res.json({ ok: true, data: "Successfully created link" }))
.catch((error) => {
console.log(error);
return res.status(500).json({ ok: false, error });
});
};
export default withIronSession(handler);

@ -1,54 +0,0 @@
import bcrypt from "bcrypt";
import { NextApiHandler } from "next";
import { LoginCredentials } from "@models/login";
import { Response } from "@models/response";
import { methodNotAllowed, unauthorized } from "@utils/errors";
import prisma from "@utils/prisma";
import { withIronSession } from "@utils/session";
const handle: NextApiHandler<Response> = async (req, res) => {
if (req.method?.toUpperCase() !== "POST") {
res.status(405).json(methodNotAllowed);
return;
}
const loginCredentials = LoginCredentials.safeParse(req.body);
if (!loginCredentials.success) {
res.status(403).json({ ok: false, error: loginCredentials.error });
return;
}
const email = loginCredentials.data.username;
const user = await prisma.user.findUnique({ where: { email } });
if (user === null) {
res.status(401).json(unauthorized);
return;
}
const password = loginCredentials.data.password;
const isCorrect = await new Promise((resolve, reject) =>
bcrypt.compare(password, user.password, (err, result) => {
if (err) reject(err);
else if (result !== undefined) resolve(result);
})
);
if (!isCorrect) {
res.status(401).json(unauthorized);
return;
}
req.session.user = user;
await req.session.save();
res.json({ ok: true, data: "Login successfull" });
};
export default withIronSession(handle);

@ -1,28 +0,0 @@
import { NextApiHandler } from "next";
import { Response } from "@models/response";
import { methodNotAllowed, unauthorized } from "@utils/errors";
import { withIronSession } from "@utils/session";
const handle: NextApiHandler<Response> = (req, res) => {
if (req.method?.toUpperCase() != "GET") {
res.status(405).json(methodNotAllowed);
return;
}
const user = req.session.user;
if (user === undefined) {
res.status(401).json(unauthorized);
return;
}
req.session.destroy();
req.session.user = undefined;
res.json({ ok: true, data: "Logout successfull" });
};
export default withIronSession(handle);

@ -1,17 +0,0 @@
import { NextApiHandler } from "next";
import { Response } from "@models/response";
import { registrationIsEnabled } from "@utils/config";
import { methodNotAllowed } from "@utils/errors";
const handler: NextApiHandler<Response> = (req, res) => {
if (req.method?.toUpperCase() !== "GET") {
res.status(405).json(methodNotAllowed);
return;
}
res.json({ ok: true, data: { registrationIsEnabled } });
};
export default handler;

@ -1,62 +0,0 @@
import bcrypt from "bcrypt";
import { NextApiHandler } from "next";
import { Response } from "@models/response";
import { SignupCredentials } from "@models/signup";
import { registrationIsEnabled, saltRoundsForPassword } from "@utils/config";
import { methodNotAllowed } from "@utils/errors";
import prisma from "@utils/prisma";
import { withIronSession } from "@utils/session";
const handle: NextApiHandler<Response> = async (req, res) => {
if (!registrationIsEnabled) {
res
.status(403)
.json({ ok: false, error: "Registration is not enabled on this server" });
return;
}
if (req.method?.toUpperCase() != "POST") {
res.status(405).json(methodNotAllowed);
return;
}
const signupCredentials = SignupCredentials.safeParse(req.body);
if (!signupCredentials.success) {
res.status(403).json({ ok: false, error: signupCredentials.error });
return;
}
const password: string = await new Promise((resolve, reject) =>
bcrypt.hash(
signupCredentials.data.password,
saltRoundsForPassword,
(err, hash) => {
if (err) return reject(err);
else if (hash) return resolve(hash);
}
)
);
await prisma.user
.create({
data: {
email: signupCredentials.data.email,
name: signupCredentials.data.name,
password,
admin: process.env.ADMIN_EMAIL === signupCredentials.data.email
}
})
.then(async (user) => {
req.session.user = user;
await req.session.save();
res.json({ ok: true, data: "Signup successfull" });
})
.catch((error) => res.status(500).json({ ok: false, error }));
};
export default withIronSession(handle);

@ -1,24 +0,0 @@
import { NextApiHandler } from "next";
import { Response } from "@models/response";
import { methodNotAllowed, unauthorized } from "@utils/errors";
import { withIronSession } from "@utils/session";
const handler: NextApiHandler<Response> = (req, res) => {
if (req.method?.toUpperCase() !== "GET") {
res.status(405).json(methodNotAllowed);
return;
}
const user = req.session.user;
if (user === undefined) {
res.status(401).json(unauthorized);
return;
}
res.json({ ok: true, data: user });
};
export default withIronSession(handler);

@ -1,3 +0,0 @@
.body {
padding: 10rem 0;
}

@ -1,84 +0,0 @@
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import { NextSeo } from "next-seo";
import styles from "./[id].module.scss";
import { Post, User } from "@prisma/client";
import prisma from "@utils/prisma";
import Layout from "@components/Layout";
import Tags from "@components/Tags";
const PostPage: NextPage<{
post: Post & {
author: User;
};
}> = ({ post }) => {
return (
<Layout>
<NextSeo title={post.title} />
<div className={styles.body}>
<div className="container">
<div className="columns">
<div className="column col-2" />
<div className="column col-6 col-md-12">
<h1>{post.title}</h1>
<div className="divider" />
<h4>
by {post.author.name} on{" "}
{new Date(post.createdAt).toLocaleDateString()}
</h4>
<Tags tags={post.tags} />
<p style={{ whiteSpace: "pre-line" }} className="mt-2">
{post.content}
</p>
</div>
</div>
</div>
</div>
</Layout>
);
};
export const getStaticPaths: GetStaticPaths = async ({}) => {
const posts = await prisma.post
.findMany({
where: { published: true }
})
.catch(() => []);
const paths = posts.map((post) => ({
params: { id: post.id.toString() }
}));
// { fallback: false } means other routes should 404
return { paths, fallback: false };
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
if (!params?.id || Array.isArray(params?.id)) return { notFound: true };
const postId = parseInt(params?.id);
if (Number.isNaN(postId)) return { notFound: true };
const post = await prisma.post.findUnique({
where: { id: postId },
include: { author: true }
});
if (post === null) return { notFound: true };
return {
props: {
post: {
...post,
createdAt: post.createdAt.toString()
}
},
revalidate: 60 * 10
};
};
export default PostPage;

@ -1,9 +0,0 @@
.body {
padding: 10rem 1rem;
@media (max-width: 840px) {
padding: 2rem 1rem;
}
min-height: 100vh;
}

@ -1,76 +0,0 @@
import { GetStaticProps, NextPage } from "next";
import { NextSeo } from "next-seo";
import styles from "./blog.module.scss";
import { Post, User } from "@prisma/client";
import prisma from "@utils/prisma";
import Layout from "@components/Layout";
import PostComponent from "@components/Post";
const Blog: NextPage<{
posts: (Post & {
author: User;
})[];
}> = ({ posts }) => {
return (
<Layout>
<NextSeo title="Blog" />
<div className={styles.body}>
<div className="container">
<div className="columns">
<div className="column col-8 col-md-12 col-mx-auto">
<h1>Latest posts</h1>
<div className="divider" />
</div>
</div>
{posts.length < 1 && (
<div className="columns">
<div className="column col-8 col-md-12 col-mx-auto">
<h3>No posts yet</h3>
</div>
</div>
)}
{posts.length > 0 &&
posts.map((post) => <PostComponent key={post.id} post={post} />)}
</div>
</div>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async (
{
// query
}
) => {
// let cursor = 0;
// if (!Array.isArray(query.cursor) && query.cursor !== undefined) {
// cursor = parseInt(query.cursor);
// }
const posts = await prisma.post
.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 5,
include: { author: true }
})
.catch(() => []);
return {
props: {
posts: posts.map((post) => ({
...post,
createdAt: post.createdAt.toString(),
content: post.content?.trim().split("\n")[0]
}))
},
revalidate: 60 * 1
};
};
export default Blog;

@ -1,166 +0,0 @@
import { NextPage } from "next";
import { NextSeo } from "next-seo";
import { useRouter } from "next/router";
import { FormEvent, useCallback, useState } from "react";
import axios from "axios";
import { User } from "@prisma/client";
import { Post } from "@models/post";
import { Response } from "@models/response";
import { parseUserInputError } from "@utils/errors";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
import { withSessionSsr } from "@utils/session";
import EmptyPage from "@components/EmptyPage";
import Layout from "@components/Layout";
const NewPostPage: NextPage<{ user: User }> = ({ user }) => {
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [content, setContent] = useState("");
const [publish, setPublish] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const createPost = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const parseUserInputResult = Post.safeParse({
title,
tags,
content,
publish
});
if (!parseUserInputResult.success) {
setError(parseUserInputError(parseUserInputResult.error.message));
return;
}
const response: Response = await axios
.post("/api/blog/new", parseUserInputResult.data)
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (response.ok) {
router.push("/blog");
} else {
setError(JSON.stringify(response.error));
}
},
[title, tags, content, publish, router]
);
return (
<Layout>
<NextSeo title="New post" />
<EmptyPage>
<div className="columns">
<div className="column col-8 col-md-12 col-mx-auto">
<h2>Create new post</h2>
<h5>Logged in as {user.name}</h5>
<form onSubmit={createPost}>
<div className="form-group">
<label className="form-label" htmlFor="title">
Post title
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
required
className="form-input"
name="title"
type="text"
id="title"
placeholder="Title"
/>
<label className="form-label" htmlFor="tags">
Tags
</label>
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
required
className="form-input"
name="tags"
type="text"
id="tags"
placeholder="A space seperated list of tags"
/>
<label className="form-label" htmlFor="content">
Content
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ resize: "none" }}
placeholder="Some content"
required
className="form-input"
name="content"
id="content"
minLength={100}
cols={30}
rows={10}
/>
<label className="form-checkbox">
<input
checked={publish}
onChange={() => setPublish((state) => !state)}
name="published"
type="checkbox"
/>
<i className="form-icon" /> Publish
</label>
{error !== null && (
<div className="toast toast-error my-2">
<button
className="btn btn-clear float-right"
onClick={() => setError(null)}
/>
{error}
</div>
)}
<input
className={"btn btn-primary"}
type="submit"
value="Create post"
/>
</div>
</form>
</div>
</div>
</EmptyPage>
</Layout>
);
};
export const getServerSideProps = withSessionSsr(({ req }) => {
const user = req.session.user;
if (user === undefined) {
return {
notFound: true
};
}
return {
props: {
user: req.session.user
}
};
});
export default NewPostPage;

@ -1,28 +0,0 @@
import { GetStaticProps, InferGetStaticPropsType, NextPage } from "next";
import { NextSeo } from "next-seo";
import Landing from "@models/landing";
import { readLandingJson } from "@utils/landing";
import Layout from "@components/Layout";
import User from "@components/User";
export const getStaticProps: GetStaticProps<Landing> = async () => {
const landing = await readLandingJson();
if (landing === null) return { revalidate: 1, notFound: true };
return { props: landing };
};
const Index: NextPage<InferGetStaticPropsType<typeof getStaticProps>> = (
landing
) => (
<Layout>
<NextSeo title="Home" />
<User owner={landing.owner} />
</Layout>
);
export default Index;

@ -1,22 +0,0 @@
import { GetServerSideProps, NextPage } from "next";
import prisma from "@utils/prisma";
const LinkRedirectPage: NextPage = () => {
return <></>;
};
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const location = params?.location;
if (location === undefined || typeof location !== "string")
return { notFound: true };
const link = await prisma.link.findFirst({ where: { location } });
if (link === null) return { notFound: true };
return { redirect: { destination: link.remoteAddress, permanent: false } };
};
export default LinkRedirectPage;

@ -1,26 +0,0 @@
import { GetServerSideProps, NextPage } from "next";
import z from "zod";
import { LinkIdFromString } from "@models/link";
import { withSessionSsr } from "@utils/session";
const EditLinkPage: NextPage = () => {
return <></>;
};
export const getServerSideProps: GetServerSideProps = withSessionSsr(
async ({ req, params }) => {
const user = req.session.user;
if (user === undefined) return { notFound: true };
const parseParamsResult = z
.object({ id: LinkIdFromString })
.safeParse(params);
if (!parseParamsResult.success) return { notFound: true };
}
);
export default EditLinkPage;

@ -1,62 +0,0 @@
import { GetStaticProps, NextPage } from "next";
import { NextSeo } from "next-seo";
import Link from "next/link";
import { Link as LinkType } from "@prisma/client";
import useUser from "@utils/hooks/useUser";
import prisma from "@utils/prisma";
import EmptyPage from "@components/EmptyPage";
import Layout from "@components/Layout";
import LinkComponent from "@components/LinkComponent";
const LinksPage: NextPage<{ links: LinkType[] }> = ({ links }) => {
const user = useUser();
return (
<Layout>
<NextSeo title="Links" />
<EmptyPage>
<div className="columns">
<div className="col col-8 col-mx-auto">
<h1>Links</h1>
<div className="container">
<div className="columns">
<div className="col col-11">
<h5>This is a collection of quick usefull links</h5>
</div>
{user !== null && (
<div className="col col-1">
<Link href="/link/new">
<a>new</a>
</Link>
</div>
)}
</div>
</div>
</div>
<div className="col col-8 col-mx-auto">
{links.length < 1 && <h2>No links yet</h2>}
{links.map((link) => (
<LinkComponent key={link.id} link={link} />
))}
</div>
</div>
</EmptyPage>
</Layout>
);
};
export const getStaticProps: GetStaticProps = async () => {
const links = await prisma.link
.findMany({
orderBy: { id: "desc" },
take: 5
})
.catch(() => []);
return { props: { links }, revalidate: 1 * 60 };
};
export default LinksPage;

@ -1,132 +0,0 @@
import { GetServerSideProps, NextPage } from "next";
import { NextSeo } from "next-seo";
import { useRouter } from "next/router";
import { FormEvent, useCallback, useState } from "react";
import axios from "axios";
import { User } from "@prisma/client";
import Link from "@models/link";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
import { withSessionSsr } from "@utils/session";
import EmptyPage from "@components/EmptyPage";
import Layout from "@components/Layout";
const NewLinkPage: NextPage<{ user: User }> = ({ user }) => {
const [remoteAddress, setRemoteAddress] = useState("");
const [localAddress, setLocalAddress] = useState("");
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const host =
typeof window !== "undefined" && "location" in window
? location.host
: "guusvanmeerveld.dev";
const createLink = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const parsedLinkResult = Link.safeParse({
remoteAddress,
location: localAddress
});
if (!parsedLinkResult.success) {
setError(parsedLinkResult.error.message);
return;
}
const response = await axios
.post("/api/link/new", parsedLinkResult.data)
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (!response.ok) {
setError(JSON.stringify(response.error));
return;
}
router.push("/link");
},
[remoteAddress, localAddress, router]
);
return (
<Layout>
<NextSeo title="New link" />
<EmptyPage>
<div className="columns">
<div className="col col-6 col-mx-auto">
<h2>Create a new link</h2>
<form onSubmit={createLink}>
<label className="form-label" htmlFor="remote-address">
Remote address
</label>
<input
required
className="form-input"
onChange={(e) => setRemoteAddress(e.target.value)}
name="remoteAddress"
type="text"
id="remote-address"
placeholder="https://example.com"
/>
<label className="form-label" htmlFor="local-address">
Address on the server
</label>
<div className="input-group">
<span className="input-group-addon">{host}/link/</span>
<input
required
className="form-input"
onChange={(e) => setLocalAddress(e.target.value)}
name="local-address"
type="text"
id="local-address"
placeholder="example"
/>
</div>
{error !== null && (
<div className="toast toast-error mt-2">
<button
className="btn btn-clear float-right"
onClick={() => setError(null)}
/>
{error}
</div>
)}
<input
type="submit"
className="btn btn-primary mt-2"
value="Create new link"
/>
</form>
</div>
</div>
</EmptyPage>
</Layout>
);
};
export const getServerSideProps: GetServerSideProps = withSessionSsr(
async ({ req }) => {
const user = req.session.user;
if (user === undefined)
return {
notFound: true
};
return { props: { user } };
}
);
export default NewLinkPage;

@ -1,21 +0,0 @@
.body {
height: 100vh;
padding: 10rem 0;
}
.title {
text-align: center;
}
.divider {
margin: 0 1rem;
}
.loginButton,
.signupButton {
width: 100%;
}
.signupButton {
margin-top: 0.5rem;
}

@ -1,84 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { NextPage } from "next";
import { NextSeo } from "next-seo";
import z from "zod";
import styles from "./login.module.scss";
import axios from "axios";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
import multipleClassNames from "@utils/multipleClassNames";
import Layout from "@components/Layout";
import LoginForm from "@components/LoginForm";
import SignupForm from "@components/SignupForm";
const SettingsModel = z.object({ registrationIsEnabled: z.boolean() });
const Login: NextPage = () => {
const { data, isLoading, error } = useQuery<
z.infer<typeof SettingsModel>,
string
>(["settings"], async (): Promise<z.infer<typeof SettingsModel>> => {
const response = await axios
.get("/api/settings")
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (!response.ok) {
throw JSON.stringify(response.error);
}
const parseSettingsResult = SettingsModel.safeParse(response.data);
if (!parseSettingsResult.success) {
throw parseSettingsResult.error.message;
}
return parseSettingsResult.data;
});
const registrationEnabled =
data !== undefined && data.registrationIsEnabled && error === null;
return (
<Layout>
<NextSeo title="Login" />
<div className={styles.body}>
<div className="columns">
<div
className={`col-md-4 ${
registrationEnabled ? "col-ml-auto" : "col-mx-auto"
}`}
>
<h2 className={styles.title}>Login to blog</h2>
<LoginForm />
</div>
{registrationEnabled && (
<>
<div
className={multipleClassNames("divider-vert", styles.divider)}
data-content="OR"
/>
<div className="col-md-4 col-mr-auto">
<h2 className={styles.title}>Create new account</h2>
<SignupForm />
</div>
</>
)}
</div>
{isLoading && (
<div className="columns">
<div className="col col-mx-auto">
<div className="loading loading-lg" />
</div>
</div>
)}
</div>
</Layout>
);
};
export default Login;

@ -1,107 +0,0 @@
[data-theme="dark"] {
body {
background-color: $bg-dark;
color: $text-primary;
}
.empty {
background-color: $bg-dark-secondary;
color: $text-primary;
}
.card {
background-color: $bg-dark;
}
.form-input,
.form-icon {
background-color: $bg-dark-secondary !important;
color: $text-primary;
border-color: lighten($bg-dark-secondary, 10%);
}
.divider-vert {
&::before {
border-color: lighten($bg-dark-secondary, 10%);
}
&[data-content]::after {
background-color: $bg-dark;
}
}
.bg-gray {
background-color: $bg-dark-secondary !important;
}
.chip,
.input-group .input-group-addon {
background-color: lighten($bg-dark-secondary, 10%);
border-color: lighten($bg-dark-secondary, 20%)
}
.divider {
border-color: $bg-dark-secondary;
}
.table {
td,
th {
border-color: lighten($bg-dark-secondary, 10%);
}
}
.table tbody tr.active,
.table.table-striped tbody tr.active {
background: $bg-dark-secondary;
}
a:hover {
color: $primary-color-dark;
}
a {
color: $primary-color;
}
.text-primary {
color: $primary-color !important;
}
.bg-primary {
background-color: $primary-color !important;
}
.btn {
background-color: $bg-dark-secondary;
color: $primary-color;
border-color: $primary-color;
&:hover {
border-color: $primary-color;
}
&:focus {
border-color: $primary-color;
}
}
.btn-primary {
background-color: $primary-color;
border-color: $primary-color;
color: $text-primary;
&:hover {
background-color: $primary-color-dark;
border-color: $primary-color-dark;
}
&:focus {
background-color: $primary-color-dark;
border-color: $primary-color-dark;
}
}
}

@ -1,13 +0,0 @@
$light-color: #eee;
$dark-color: #212121;
$primary-color: #7e57c2;
$dark-color-secondary: lighten($dark-color, 2%);
$primary-color-dark: darken($primary-color, 10%);
$bg-dark: $dark-color;
$bg-dark-secondary: $dark-color-secondary;
$text-primary: $light-color;
$text-secondary: darken($light-color, 10%);

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

@ -1,10 +0,0 @@
@use "spectre.css/src/spectre.scss";
@use "spectre.css/src/spectre-icons.scss";
@import "variables";
@import "dark";
p {
line-height: 1.25rem;
}

@ -0,0 +1,3 @@
import { FC, PropsWithChildren } from "react";
export type Component<P = unknown> = FC<PropsWithChildren<P>>;

@ -0,0 +1,6 @@
import { Component } from "./component";
export type ErrorPage = Component<{
error: Error & { digest?: string };
reset: () => void;
}>;

@ -1,19 +0,0 @@
export const sessionCookieName = "portfolio-session";
export const sessionPassword = process.env.SESSION_PASSWORD ?? "";
export const sessionOptions = {
cookieName: sessionCookieName,
password: sessionPassword,
cookieOptions: {
secure: process.env.NODE_ENV === "production"
}
};
export const registrationIsEnabled =
process.env.ALLOW_REGISTRATION !== undefined ? true : false;
export const saltRoundsForPassword =
parseInt(process.env.PASSWORD_SALT_ROUNDS ?? "") || 10;
export const landingJsonLocation =
process.env.LANDING_JSON_LOCATION ?? "/app/landing.json";

@ -0,0 +1,3 @@
export const dataDirLocation = process.env.DATA_DIR ?? "/app/data";
export const avatarFileFormat = process.env.AVATAR_FILE_FORMAT ?? "jpg";

@ -1,17 +0,0 @@
import z from "zod";
import { Response } from "@models/response";
const baseError = (error: string): Response => ({
error,
ok: false
});
export const methodNotAllowed: Response = baseError("Method not allowed");
export const unauthorized: Response = baseError(
"Could not login; incorrect email or password"
);
export const parseUserInputError: (error: unknown) => string = (error) =>
"Failed to parse user input: ".concat(JSON.stringify(error));

@ -1,23 +0,0 @@
import { AxiosError, AxiosResponse } from "axios";
import ResponseModel, { ErrorModel, Response } from "@models/response";
export const parseAxiosResponse = (res: AxiosResponse<unknown>): Response => {
const parseResponseResult = ResponseModel.safeParse(res.data);
if (!parseResponseResult.success) {
return { ok: false, error: parseResponseResult.error };
}
return parseResponseResult.data;
};
export const parseAxiosError = (e: AxiosError<unknown>): Response => {
const parseErrorResult = ErrorModel.safeParse(e.response?.data);
if (!parseErrorResult.success) {
return { ok: false, error: parseErrorResult.error };
}
return parseErrorResult.data;
};

@ -1,7 +1,9 @@
import { stat } from "fs-extra";
export const exists = async (fileName: string): Promise<boolean> => {
const fileExists = async (fileName: string): Promise<boolean> => {
return await stat(fileName)
.then(() => true)
.catch(() => false);
};
export default fileExists;

@ -1,44 +0,0 @@
import z from "zod";
import axios from "axios";
import {
RepositoryResponse,
SearchResultsResponse,
UserResponse
} from "@models/git/responses";
import { giteaServerUrl } from "@utils/config";
const apiUrl = `https://${giteaServerUrl}/api/v1`;
export const fetchUser = async (
giteaUsername: string
): Promise<z.infer<typeof UserResponse>> => {
const { data: user } = await axios.get<unknown>(
`${apiUrl}/users/${giteaUsername}`
);
return UserResponse.parse(user);
};
export const fetchRepositories = async (
giteaUserUid: number
): Promise<z.infer<typeof RepositoryResponse>[]> => {
const { data: repositories } = await axios.get<unknown>(
`${apiUrl}/repos/search`,
{
params: {
topic: true,
q: "on-portfolio",
id: giteaUserUid,
limit: 6
}
}
);
const results = SearchResultsResponse.parse(repositories);
if (results.ok) return results.data;
else throw results.data;
};

@ -1,35 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { User } from "@prisma/client";
import { User as UserModel } from "@models/user";
import { parseAxiosError, parseAxiosResponse } from "@utils/fetch";
const useUser = (): User | null => {
const { data } = useQuery(
["user"],
async () => {
const response = await axios
.get("/api/user")
.then(parseAxiosResponse)
.catch(parseAxiosError);
if (response.ok) return response.data;
else throw response.error;
},
{ retry: () => false, enabled: typeof window !== "undefined" }
);
if (typeof window === "undefined") return null;
const parseUserResult = UserModel.safeParse(data);
if (parseUserResult.success) {
return parseUserResult.data;
} else return null;
};
export default useUser;

@ -0,0 +1,46 @@
import { readFile, readJson } from "fs-extra";
import path from "path";
import { avatarFileFormat } from "./constants";
import Landing, { LandingModel } from "@models/landing";
import exists from "@utils/fileExists";
export const readLandingJson = async (
dataDirLocation: string
): Promise<Landing> => {
const landingJsonLocation = path.join(dataDirLocation, "landing.json");
const fileExists = await exists(landingJsonLocation);
if (!fileExists) {
throw new Error(
`Could not find landing json file at: ${landingJsonLocation}`
);
}
const rawJson: unknown = await readJson(landingJsonLocation);
const landingResult = LandingModel.safeParse(rawJson);
if (!landingResult.success)
throw new Error(`Failed to parse landing json: ${landingResult.error}`);
return landingResult.data;
};
export const readAvatarFile = async (
dataDirLocation: string
): Promise<string> => {
const avatarFileLocation = path.join(
dataDirLocation,
`avatar.${avatarFileFormat}`
);
const imageData = await readFile(avatarFileLocation);
const base64Image = Buffer.from(imageData).toString("base64");
return `data:image/${avatarFileFormat};base64,${base64Image}`;
};

@ -1,30 +0,0 @@
import { readJson } from "fs-extra";
import Landing, { LandingModel } from "@models/landing";
import { landingJsonLocation } from "@utils/config";
import { exists } from "@utils/exists";
export const readLandingJson = async (): Promise<Landing | null> => {
const location = landingJsonLocation;
const fileExists = await exists(location);
if (!fileExists) {
console.log(`Could not find landing json file at: ${location}`);
return null;
}
const rawJson: unknown = await readJson(location);
const landingResult = LandingModel.safeParse(rawJson);
if (!landingResult.success) {
console.log(`Failed to parse landing json: ${landingResult.error}`);
return null;
}
return landingResult.data;
};

@ -1,5 +0,0 @@
const multipleClassNames = (...classNames: string[]): string => {
return classNames.join(" ");
};
export default multipleClassNames;

@ -1,18 +0,0 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient;
}
let prisma: PrismaClient;
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
if (!global.prisma) {
global.prisma = new PrismaClient();
}
prisma = global.prisma;
}
export default prisma;

@ -1,29 +0,0 @@
import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";
import {
GetServerSidePropsContext,
GetServerSidePropsResult,
NextApiHandler
} from "next";
import { sessionOptions } from "./config";
import { User } from "@prisma/client";
export const withIronSession = (handler: NextApiHandler) =>
withIronSessionApiRoute(handler, sessionOptions);
export function withSessionSsr<
P extends { [key: string]: unknown } = { [key: string]: unknown }
>(
handler: (
context: GetServerSidePropsContext
) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>
) {
return withIronSessionSsr(handler, sessionOptions);
}
declare module "iron-session" {
interface IronSessionData {
user?: User;
}
}

@ -0,0 +1,16 @@
import { nextui } from "@nextui-org/react";
/** @type {import('tailwindcss').Config} */
const config = {
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}"
],
theme: {
extend: {}
},
darkMode: "class",
plugins: [nextui()]
};
export default config;

@ -1,30 +1,61 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"tsBuildInfoFile": ".next/tsbuildinfo.json",
"baseUrl": "src",
"paths": {
"@styles/*": ["styles/*"],
"@components/*": ["components/*"],
"@interfaces/*": ["interfaces/*"],
"@models/*": ["models/*"],
"@utils/*": ["utils/*"],
"@src/*": ["src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"tsBuildInfoFile": ".next/tsbuildinfo.json",
"baseUrl": "src",
"paths": {
"@styles/*": [
"styles/*"
],
"@typings/*": [
"typings/*"
],
"@components/*": [
"components/*"
],
"@interfaces/*": [
"interfaces/*"
],
"@models/*": [
"models/*"
],
"@utils/*": [
"utils/*"
],
"@src/*": [
"src/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save