Started on links page
continuous-integration/drone/push Build is passing Details

main
Guus van Meerveld 1 year ago
parent fe425d65cc
commit ae4a38d1b9
Signed by: Guusvanmeerveld
GPG Key ID: 2BA7D7912771966E

@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "Link" (
"id" SERIAL NOT NULL,
"remoteAddress" TEXT NOT NULL,
"location" TEXT NOT NULL,
"authorId" INTEGER NOT NULL,
CONSTRAINT "Link_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Link" ADD CONSTRAINT "Link_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

@ -17,6 +17,7 @@ model User {
password String
name String
posts Post[]
links Link[]
}
model Post {
@ -30,3 +31,12 @@ model Post {
author User @relation(fields: [authorId], references: [id])
authorId Int
}
model Link {
id Int @id @default(autoincrement())
remoteAddress String
location String
author User @relation(fields: [authorId], references: [id])
authorId Int
}

@ -0,0 +1,13 @@
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;

@ -12,9 +12,11 @@ const Footer: FC = () => {
<footer className={multipleClassNames("container", styles.main)}>
<div className="columns">
<div className="column col-8 col-md-12 col-mx-auto">
<h3>Guus van Meerveld</h3>
<div className="columns mb-2">
<div className="column col-12">
<div className="col col-12">
<h3>Guus van Meerveld</h3>
</div>
<div className="col col-12">
<Link href={`https://${giteaServerUrl}/${giteaUsername}`}>
<a className="mr-2">Git</a>
</Link>
@ -31,17 +33,37 @@ const Footer: FC = () => {
<a className="mx-2">Youtube</a>
</Link>
</div>
<div className="col col-12">
<span>Pages: </span>
<Link href={{ pathname: "/" }}>
<a className="mr-2">Home</a>
</Link>
&middot;
<Link href={{ pathname: "/blog" }}>
<a className="mx-2">Blog</a>
</Link>
&middot;
<Link href={{ pathname: "/link" }}>
<a className="mx-2">Links</a>
</Link>
&middot;
<Link href={{ pathname: "/login" }}>
<a className="mx-2">Login</a>
</Link>
</div>
<div className="col col-12">
<p>
Built with{" "}
<span role="img" aria-label="heart emoji">
</span>{" "}
by Guus van Meerveld, using{" "}
<Link href="https://picturepan2.github.io/spectre">
<a>Spectre.css</a>
</Link>
</p>
</div>
</div>
<p>
Built with{" "}
<span role="img" aria-label="heart emoji">
</span>{" "}
by Guus van Meerveld, using{" "}
<Link href="https://picturepan2.github.io/spectre">
<a>Spectre.css</a>
</Link>
</p>
</div>
</div>
</footer>

@ -0,0 +1,67 @@
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 { 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 = z.number().safeParse(id);
if (!parseUserInputResult.success) {
setError(parseUserInputResult.error.message);
return;
}
const response = await axios
.post("/api/link/delete", { 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">
<a className="mr-2">edit</a>
</Link>
<a onClick={() => deleteLink(link.id)}>delete</a>
</div>
)}
</div>
</div>
</div>
);
};
export default LinkComponent;

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

@ -0,0 +1,4 @@
.body {
padding: 1rem;
margin-bottom: 1rem;
}

@ -0,0 +1,8 @@
import z from "zod";
const Link = z.object({
remoteAddress: z.string().url(),
location: z.string()
});
export default Link;

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

@ -7,7 +7,9 @@ import SEO from "../next-seo.config";
import "@styles/globals.scss";
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } }
});
const App = ({ Component, pageProps }: AppProps): JSX.Element => (
<>

@ -0,0 +1,43 @@
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);

@ -40,7 +40,7 @@ const handle: NextApiHandler<Response> = async (req, res) => {
)
);
prisma.user
await prisma.user
.create({
data: {
email: signupCredentials.data.email,

@ -0,0 +1,24 @@
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,4 +0,0 @@
.body {
height: 100vh;
padding: 10rem 0;
}

@ -2,8 +2,6 @@ import { NextPage } from "next";
import { NextSeo } from "next-seo";
import { useRouter } from "next/router";
import styles from "./new.module.scss";
import { FormEvent, useCallback, useState } from "react";
import axios from "axios";
@ -17,6 +15,7 @@ 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 }) => {
@ -62,90 +61,88 @@ const NewPostPage: NextPage<{ user: User }> = ({ user }) => {
return (
<Layout>
<NextSeo title="New post" />
<div className={styles.body}>
<div className="container">
<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>
<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
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}
checked={publish}
onChange={() => setPublish((state) => !state)}
name="published"
type="checkbox"
/>
<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)}
/>
<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>
{error}
</div>
)}
<input
className={"btn btn-primary"}
type="submit"
value="Create post"
/>
</div>
</form>
</div>
</div>
</div>
</EmptyPage>
</Layout>
);
};

@ -0,0 +1,22 @@
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;

@ -0,0 +1,62 @@
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;

@ -0,0 +1,132 @@
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;

@ -34,8 +34,10 @@
background-color: $bg-dark-secondary !important;
}
.chip {
.chip,
.input-group .input-group-addon {
background-color: lighten($bg-dark-secondary, 10%);
border-color: lighten($bg-dark-secondary, 20%)
}
.divider {
@ -43,6 +45,7 @@
}
.table {
td,
th {
border-color: lighten($bg-dark-secondary, 10%);
@ -101,4 +104,4 @@
border-color: $primary-color-dark;
}
}
}
}

@ -0,0 +1,35 @@
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;
Loading…
Cancel
Save