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
continuous-integration/drone/push Build is passing
Details
Reviewed-on: #1main
commit
a789cd33fb
@ -1,2 +1,2 @@
|
||||
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";
|
||||
|
||||
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;
|
Loading…
Reference in new issue