Migrated websites ui to nextui using the newest Next.js version #1
Merged
Guusvanmeerveld
merged 13 commits from nextui
into main
9 months ago
@ -1,2 +1,2 @@
|
|||||||
DATABASE_URL=postgresql://portfolio:portfolio@localhost:5432/portfolio?schema=public
|
DATABASE_URL=postgresql://portfolio:portfolio@localhost:5432/portfolio?schema=public
|
||||||
LANDING_JSON_LOCATION=./landing.json
|
DATA_DIR=./data
|
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,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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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">
|
||||||
|
❤️
|
||||||
|
</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,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,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";
|
import { stat } from "fs-extra";
|
||||||
|
|
||||||
export const exists = async (fileName: string): Promise<boolean> => {
|
const fileExists = async (fileName: string): Promise<boolean> => {
|
||||||
return await stat(fileName)
|
return await stat(fileName)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false);
|
.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": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
"allowJs": true,
|
"dom",
|
||||||
"skipLibCheck": true,
|
"dom.iterable",
|
||||||
"strict": true,
|
"esnext"
|
||||||
"forceConsistentCasingInFileNames": true,
|
],
|
||||||
"noEmit": true,
|
"allowJs": true,
|
||||||
"esModuleInterop": true,
|
"skipLibCheck": true,
|
||||||
"module": "esnext",
|
"strict": true,
|
||||||
"moduleResolution": "node",
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"noEmit": true,
|
||||||
"isolatedModules": true,
|
"esModuleInterop": true,
|
||||||
"jsx": "preserve",
|
"module": "esnext",
|
||||||
"incremental": true,
|
"moduleResolution": "node",
|
||||||
"tsBuildInfoFile": ".next/tsbuildinfo.json",
|
"resolveJsonModule": true,
|
||||||
"baseUrl": "src",
|
"isolatedModules": true,
|
||||||
"paths": {
|
"jsx": "preserve",
|
||||||
"@styles/*": ["styles/*"],
|
"incremental": true,
|
||||||
"@components/*": ["components/*"],
|
"tsBuildInfoFile": ".next/tsbuildinfo.json",
|
||||||
"@interfaces/*": ["interfaces/*"],
|
"baseUrl": "src",
|
||||||
"@models/*": ["models/*"],
|
"paths": {
|
||||||
"@utils/*": ["utils/*"],
|
"@styles/*": [
|
||||||
"@src/*": ["src/*"]
|
"styles/*"
|
||||||
}
|
],
|
||||||
},
|
"@typings/*": [
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"typings/*"
|
||||||
"exclude": ["node_modules"]
|
],
|
||||||
|
"@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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in new issue