Rename all old folders, create new folders and files for the app directory, setting up header/footer/layout, and getting prisma lucia auth working to create a user.

This commit is contained in:
Bradley Shellnut 2023-11-22 17:19:05 -08:00
parent 85f859f1d2
commit dd9fa6a832
167 changed files with 8309 additions and 23635 deletions

3
.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

19
.gitignore vendored
View file

@ -4,6 +4,7 @@
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
@ -25,11 +26,21 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# panda css
styled-system
# JetBrains
.idea
.fleet

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
18.17

28
.vscode copy/launch.json Normal file
View file

@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "pnpm dev",
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

View file

@ -1,59 +1,36 @@
# Wedding Website
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## This is a skeleton template of the wedding website I created
## Getting Started
Features include:
- Password login for site access
- RSVP page for groups or individuals
- Pages that include:
- Home Page
- Wedding Party
- Photos pages
- Q&A
- Travel information
- RSVP forms
First, run the development server:
## Detailed Info
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Names, Dates, Locations are all hardcoded to a value
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
The site implements a basic auth with [next-iron-session](https://github.com/vvo/next-iron-session) to protect access without knowing the password to the site.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
The code is set up to use a MongoDB instace, ENV MONGO_URL, but this could easily be swapped for any DB. For the purposes of deploying this template for viewing the data is hardcoded.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
Use of CSS variables at a Layout level allows for theming and is easily extensible.
## Learn More
Adding, Updating, and Deleting of guests and groups is currently done manually on the DB or on a deployment of the admin specific branch.
To learn more about Next.js, take a look at the following resources:
This admin branch is not included yet in this example site as no roles or permissions have been set up. However, this branch does include additional pages to add, edit, and delete these guests and groups.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
*If deploying to production please remove all sections that have the following:*
```// TODO: REMOVE THIS WHEN TAKING YOUR SITE TO PRODUCTION```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Tech
## Deploy on Vercel
Overall a typical NextJS Application using ReactJS and basic authentication.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
### Frontend
- ReactJS
- Styled Components
- Images
- Loaded using either the default NextJS image with custom blur animation
- Or loading using Cloudinary on NextJS image and custom blur
### Backend
- NextJS APIs
- Next Iron Session for Login
- Server side rendering of base pages checking to see if user is logged in
- Requires ENV variable of SECRET_COOKIE_PASSWORD to be set
- Mongoose DB for MongoDB
- Used to store RSVPs and default logins
## Future Changes
1. On/Off feature for public vs password protected sites
2. Build in auth permissions to allow guest vs admin roles
3. If roles available then add in the admin pages for create, update, and deletion of guests/groups
4. Add more theming options and easy customization of pages, resources, etc.
5. Email reminder option
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View file

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import HomePage from '../pages';
describe('Index Page <HomePage />', () => {
it('should render', () => {
render(<HomePage />);
});
});

View file

@ -1,15 +0,0 @@
import { render, screen } from '@testing-library/react';
import Nav from '../components/Nav';
const useRouter = jest.spyOn(require('next/router'), 'useRouter');
describe('<Nav/>', () => {
it('Renders nav correctly and matches snapshot', () => {
const router = { pathname: '/' };
useRouter.mockReturnValue(router);
const { container, debug } = render(<Nav />);
expect(container).toMatchSnapshot();
const link = screen.getByText('RSVP');
expect(link).toHaveAttribute('href', '/rsvp');
});
});

View file

@ -1,46 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Nav/> Renders nav correctly and matches snapshot 1`] = `
<div>
<nav
class="NavStyles-sc-vewc8g-0 dWZdml"
>
<a
aria-current="page"
href="/"
>
Home
</a>
<a
href="/story"
>
Our Story
</a>
<a
href="/party"
>
Wedding Party
</a>
<a
href="/photos"
>
Photos
</a>
<a
href="/travelstay"
>
Travel & Stay
</a>
<a
href="/qanda"
>
Q + A
</a>
<a
href="/rsvp"
>
RSVP
</a>
</nav>
</div>
`;

View file

@ -1,22 +0,0 @@
import buildBase64Data from '../utils/buildBase64Data';
describe('build base 64 function', () => {
const imageName = 'https://picsum.photos/1307/880';
const alt = 'test alt';
it('takes an image name and builds base64 image', async () => {
const imageData = await buildBase64Data(false, imageName, alt, {});
expect(imageData).toBeDefined();
expect(imageData.alt).toEqual(alt);
expect(imageData.imageProps).toBeDefined();
expect(imageData.imageProps.blurDataURL).toContain(
'data:image/jpeg;base64'
);
expect(imageData.imageProps.height).toBeGreaterThan(0);
expect(imageData.imageProps.width).toBeGreaterThan(0);
expect(imageData.imageProps.src).toContain(imageName);
});
it('fails if image not resolved', async () => {
expect(await buildBase64Data(false, 'Blah', alt, {})).toEqual({});
});
});

View file

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import Home from '../pages/index';
describe('Home', () => {
it('renders home page', () => {
render(<Home />);
});
});

9
app.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
// app.d.ts
/// <reference types="lucia" />
declare namespace Lucia {
type Auth = import("./auth/lucia").Auth;
type DatabaseUserAttributes = {
username: string;
};
type DatabaseSessionAttributes = {};
}

View file

@ -0,0 +1,5 @@
'use server'
export async function SignUpAction() {
}

82
app/api/login/route.ts Normal file
View file

@ -0,0 +1,82 @@
import { auth } from "@/auth/lucia";
import * as context from "next/headers";
import { NextResponse } from "next/server";
import { LuciaError } from "lucia";
import type { NextRequest } from "next/server";
export const POST = async (request: NextRequest) => {
const formData = await request.formData();
const username = formData.get("username");
const password = formData.get("password");
// basic check
if (
typeof username !== "string" ||
username.length < 1 ||
username.length > 31
) {
return NextResponse.json(
{
error: "Invalid username",
},
{
status: 400,
}
);
}
if (
typeof password !== "string" ||
password.length < 1 ||
password.length > 255
) {
return NextResponse.json(
{
error: "Invalid password",
},
{
status: 400,
}
);
}
try {
// find user by key
// and validate password
const key = await auth.useKey("username", username.toLowerCase(), password);
const session = await auth.createSession({
userId: key.userId,
attributes: {},
});
const authRequest = auth.handleRequest(request.method, context);
authRequest.setSession(session);
return new Response(null, {
status: 302,
headers: {
Location: "/", // redirect to profile page
},
});
} catch (e) {
if (
e instanceof LuciaError &&
(e.message === "AUTH_INVALID_KEY_ID" ||
e.message === "AUTH_INVALID_PASSWORD")
) {
// user does not exist or invalid password
return NextResponse.json(
{
error: "Incorrect username or password",
},
{
status: 400,
}
);
}
return NextResponse.json(
{
error: "An unknown error occurred",
},
{
status: 500,
}
);
}
};

122
app/api/signup/route.ts Normal file
View file

@ -0,0 +1,122 @@
import { auth } from "@/auth/lucia";
import * as context from "next/headers";
import { NextResponse } from "next/server";
import { add_user_to_role } from '@/lib/roles';
import type { NextRequest } from "next/server";
type VercelPostgresError = {
code: string;
detail: string;
schema?: string;
table?: string;
column?: string;
dataType?: string;
constraint?: "auth_user_username_key";
};
export const POST = async (request: NextRequest) => {
const formData = await request.formData();
const username = formData.get("username");
const password = formData.get("password");
const adminPassword = formData.get("admin_password");
if (!adminPassword || adminPassword !== process.env.ADMIN_PASSWORD) {
return NextResponse.json(
{
error: "Failed to create user",
},
{
status: 400
}
);
}
// basic check
if (
typeof username !== "string" ||
username.length < 4 ||
username.length > 31
) {
return NextResponse.json(
{
error: "Invalid username"
},
{
status: 400
}
);
}
if (
typeof password !== "string" ||
password.length < 6 ||
password.length > 255
) {
return NextResponse.json(
{
error: "Invalid password"
},
{
status: 400
}
);
}
try {
const user = await auth.createUser({
key: {
providerId: "username", // auth method
providerUserId: username.toLowerCase(), // unique id when using "username" auth method
password // hashed by Lucia
},
attributes: {
username
}
});
console.log(`User created: ${JSON.stringify(user, null, 2)}`);
add_user_to_role(user.userId, 'admin');
const session = await auth.createSession({
userId: user.userId,
attributes: {}
});
const authRequest = auth.handleRequest(request.method, context);
authRequest.setSession(session);
return new Response(null, {
status: 302,
headers: {
Location: "/" // redirect to profile page
}
});
} catch (e) {
console.log(`Error: ${e}`);
// this part depends on the database you're using
// check for unique constraint error in user table
const maybeVercelPostgresError = (
typeof e === 'object' ? e : {}
) as Partial<VercelPostgresError>;
// error code for unique constraint violation
if (maybeVercelPostgresError.code === "23505") {
return NextResponse.json(
{
error: "Username already taken"
},
{
status: 400
}
);
}
return NextResponse.json(
{
error: "An unknown error occurred"
},
{
status: 500
}
);
}
}

35
app/layout.tsx Normal file
View file

@ -0,0 +1,35 @@
import '@/styles/globals.css';
import '@/styles/typeography.scss';
import styles from "@/styles/layout.module.scss";
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Header from '@/components/Header';
import Footer from '@/components/Footer';
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<noscript>
<h1>Please enable JavaScript to view our site.</h1>
</noscript>
<div className={styles.layout}>
<Header />
<main className={styles.content}>{children}</main>
<Footer />
</div>
</body>
</html>
);
}

29
app/login/page.tsx Normal file
View file

@ -0,0 +1,29 @@
import Link from "next/link";
import * as context from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/auth/lucia";
import Form from "@/components/form";
const Page = async () => {
const authRequest = auth.handleRequest("GET", context);
const session = await authRequest.validate();
if (session) redirect("/");
return (
<>
<h1>Sign in</h1>
<Form action="/api/login">
<label htmlFor="username">Username</label>
<input name="username" id="username" />
<br />
<label htmlFor="password">Password</label>
<input type="password" name="password" id="password" />
<br />
<input type="submit" />
</Form>
<Link href="/signup">Create an account</Link>
</>
);
};
export default Page;

17
app/not-found.tsx Normal file
View file

@ -0,0 +1,17 @@
import React from 'react';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '404 - Page not found',
}
export default function NotFound() {
return (
<>
<h1>Sorry page not found! 😿</h1>
<p>404.</p>
<p>{"You just hit a route that doesn't exist."}</p>
</>
);
}

7
app/page.tsx Normal file
View file

@ -0,0 +1,7 @@
export default function Home() {
return (
<div>
Hello 🐼!
</div>
);
}

26
app/signup/page.tsx Normal file
View file

@ -0,0 +1,26 @@
import React from "react";
import Form from "@/components/form";
import Link from 'next/link';
const Page = async () => {
return (
<>
<h1>Sign up</h1>
<Form action="/api/signup">
<label htmlFor="username">Username</label>
<input name="username" id="username" />
<br />
<label htmlFor="password">Password</label>
<input type="password" name="password" id="password" />
<br />
<label htmlFor="admin_password">Admin Password</label>
<input type="password" name="admin_password" id="admin_password" />
<br />
<input type="submit" />
</Form>
<Link href="/login">Login</Link>
</>
);
}
export default Page;

32
auth/lucia.ts Normal file
View file

@ -0,0 +1,32 @@
import { lucia } from "lucia";
import { nextjs_future } from "lucia/middleware";
import { prisma } from '@lucia-auth/adapter-prisma';
import { PrismaClient } from '@prisma/client';
import "lucia/polyfill/node";
import { cache } from "react";
import * as context from "next/headers";
const client = new PrismaClient();
// expect error (see next section)
export const auth = lucia({
adapter: prisma(client),
env: process.env.NODE_ENV === "development" ? "DEV" : "PROD",
middleware: nextjs_future(),
sessionCookie: {
expires: false,
},
getUserAttributes: (data) => {
return {
username: data.username,
};
},
});
export type Auth = typeof auth;
export const getPageSession = cache(() => {
const authRequest = auth.handleRequest("GET", context);
return authRequest.validate();
});

38
components/Footer.tsx Normal file
View file

@ -0,0 +1,38 @@
import React from "react";
import Link from "next/link";
import styles from "@/styles/layout.module.scss";
export default function Footer() {
// const { user } = useUser();
return (
<footer className={styles.footer}>
<div>
<h2>
<Link href="/">N & N</Link>
</h2>
{/* {user && user.isLoggedIn === true ? (
<>
<hr />
<h3>06.03.2030</h3>
</>
) : (
""
)} */}
</div>
<div>
<p>Created by Bradley Shellnut</p>
<div>
Icons made by{" "}
<a href="https://www.freepik.com" title="Freepik">
Freepik
</a>{" "}
from{" "}
<a href="https://www.flaticon.com/" title="Flaticon">
www.flaticon.com
</a>
</div>
</div>
</footer>
);
}

34
components/Header.tsx Normal file
View file

@ -0,0 +1,34 @@
import React from "react";
import Link from "next/link";
import styles from '@/styles/layout.module.scss';
// import useWeddingStart from "@/lib/useWeddingStart";
// import WeddingStart from "./WeddingStart";
import Nav from "./Nav";
export default function Header() {
// const { user } = useUser();
// const { timeAsDays, pastWeddingDate } = useWeddingStart({
// update: 60000,
// });
return (
<header className={styles.header}>
<div>
<Link href="/">
<h1 className="center">Name & Name</h1>
</Link>
{/* {user && user.isLoggedIn === true && !pastWeddingDate ? (
<>
<h2 className="center">June 3rd, 2030 @ New York, New York</h2>
<h3 className="center">
Countdown: <WeddingStart /> days!
</h3>
</>
) : (
""
)} */}
</div>
{/* {user && user.isLoggedIn === true ? <Nav /> : ""} */}
</header>
);
}

16
components/Nav.tsx Normal file
View file

@ -0,0 +1,16 @@
import { NavLink } from "./NavLink";
import NavStyles from "@/styles/NavStyles";
export default function Nav() {
return (
<NavStyles>
<NavLink href="/">Home</NavLink>
<NavLink href="/story">Our Story</NavLink>
<NavLink href="/party">Wedding Party</NavLink>
<NavLink href="/photos">Photos</NavLink>
<NavLink href="/travelstay">Travel & Stay</NavLink>
<NavLink href="/qanda">Q & A</NavLink>
<NavLink href="/rsvp">RSVP</NavLink>
</NavStyles>
);
}

View file

@ -1,14 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
export const NavLink = ({ href, children }) => {
const { asPath } = useRouter();
const ariaCurrent = href === asPath ? 'page' : undefined;
return (
<Link prefetch href={href} aria-current={ariaCurrent}>
{children}
</Link>
);
};

19
components/NavLink.tsx Normal file
View file

@ -0,0 +1,19 @@
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
interface NavLinkType {
href: string;
children: React.ReactNode;
}
export const NavLink: React.FC<NavLinkType> = ({ href, children }) => {
const { asPath } = useRouter();
const ariaCurrent = href === asPath ? "page" : undefined;
return (
<Link prefetch href={href} aria-current={ariaCurrent}>
{children}
</Link>
);
};

View file

@ -0,0 +1,12 @@
import React from "react";
import useWeddingStart from "@/lib/useWeddingStart";
export default function WeddingStart() {
const { timeAsDays } = useWeddingStart({
update: 60000,
});
return (
<span style={{ color: "#e64c44", fontSize: "3.157rem" }}>{timeAsDays}</span>
);
}

40
components/form.tsx Normal file
View file

@ -0,0 +1,40 @@
// components/form.tsx
"use client";
import React from "react";
import { useRouter } from "next/navigation";
const Form = ({
children,
action,
}: {
children: React.ReactNode;
action: string;
}) => {
const router = useRouter();
return (
<form
action={action}
method="post"
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const response = await fetch(action, {
method: "POST",
body: formData,
redirect: "manual",
});
if (response.status === 0) {
// redirected
// when using `redirect: "manual"`, response status 0 is returned
return router.refresh();
}
}}
>
{children}
</form>
);
};
export default Form;

View file

@ -1,10 +1,10 @@
import React from 'react';
import styled from 'styled-components';
import Link from 'next/link';
import useUser from '../lib/useUser';
import useUser from '../lib_old/useUser';
import WeddingStart from './WeddingStart';
import Nav from './Nav';
import useWeddingStart from '../lib/useWeddingStart';
import useWeddingStart from '../lib_old/useWeddingStart';
const HeaderStyles = styled.header`
display: grid;

View file

@ -0,0 +1,19 @@
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
interface NavLinkType {
href: string;
children: React.ReactNode;
}
export const NavLink: React.FC<NavLinkType> = ({ href, children }) => {
const { asPath } = useRouter();
const ariaCurrent = href === asPath ? "page" : undefined;
return (
<Link prefetch href={href} aria-current={ariaCurrent}>
{children}
</Link>
);
};

View file

@ -1,4 +1,4 @@
import useWeddingStart from '../lib/useWeddingStart';
import useWeddingStart from '../lib_old/useWeddingStart';
export default function WeddingStart() {
const { timeAsDays } = useWeddingStart({

View file

@ -1,3 +0,0 @@
import '@testing-library/jest-dom';
window.alert = console.log;

19
lib/db.ts Normal file
View file

@ -0,0 +1,19 @@
import { PrismaClient } from "@prisma/client";
const prismaClientSingleton = () => {
return new PrismaClient();
};
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
};
const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

29
lib/registry.tsx Normal file
View file

@ -0,0 +1,29 @@
"use client";
import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
export default function StyledComponentsRegistry({
children,
}: {
children: React.ReactNode;
}) {
// Only create stylesheet once with lazy initial state
// x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = styledComponentsStyleSheet.getStyleElement();
styledComponentsStyleSheet.instance.clearTag();
return <>{styles}</>;
});
if (typeof window !== "undefined") return <>{children}</>;
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
);
}

32
lib/roles.ts Normal file
View file

@ -0,0 +1,32 @@
import prisma from "./db";
export async function add_user_to_role(user_id: string, role_name: string) {
// Find the role by its name
const role = await prisma.role.findUnique({
where: {
name: role_name,
},
});
if (!role) {
throw new Error(`Role with name ${role_name} not found`);
}
// Create a UserRole entry linking the user and the role
const userRole = await prisma.userRole.create({
data: {
user: {
connect: {
id: user_id,
},
},
role: {
connect: {
id: role.id,
},
},
},
});
return userRole;
}

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import useInterval from '../utils/useInterval';
import useInterval from '@/lib/useInterval';
export default function useWeddingStart({ update = 60000 }) {
const weddingDate = 1906736400000;

69
lib_old/events.json Normal file
View file

@ -0,0 +1,69 @@
[
{
"name": "Rehearsal",
"date": "Sunday, June 2nd, 2030",
"start": "",
"end": "",
"venueName": "",
"attire": "",
"description": "Rehearsal & Rehearsal Dinner",
"openToAll": true,
"showSchedule": false,
"scheduleEvents": []
},
{
"name": "Saying Hello",
"date": "Sunday, June 2nd, 2030",
"start": "9:00 PM",
"end": "",
"venueName": "",
"attire": "",
"description": "Come hang out with us and say hi! Location TBD",
"openToAll": true,
"showSchedule": false,
"scheduleEvents": []
},
{
"name": "Saying I Do",
"date": "Monday, June 3rd, 2030",
"start": "5:00 PM",
"end": "11:00 PM",
"venueName": "<div><h4>Central Park New York, NY, USA</h4></div>",
"attire": "",
"description": "",
"openToAll": true,
"showSchedule": false,
"scheduleEvents": [
{
"name": "Ceremony",
"start": "5:00 PM",
"end": "",
"venueName": "<div><h4>Central Park New York, NY, USA</h4></div>"
},
{
"name": "Cocktail Hour",
"start": "5:30 PM",
"end": "",
"venueName": "<div><h4>Central Park New York, NY, USA</h4></div>"
},
{
"name": "Dinner, Dancing, Dessert",
"start": "7:00 PM",
"end": "",
"venueName": "<div><h4>Central Park New York, NY, USA</h4></div>"
}
]
},
{
"name": "Saying Goodbye",
"date": "Tuesday, June 4th, 2030",
"start": "9:00 AM",
"end": "11:30 AM",
"venueName": "",
"attire": "",
"description": "Farewell Brunch",
"openToAll": true,
"showSchedule": false,
"scheduleEvents": []
}
]

24
lib_old/fetchJson.js Normal file
View file

@ -0,0 +1,24 @@
export default async function fetcher(...args) {
try {
const response = await fetch(...args);
// if the server replies, there's always some data in json
// if there's a network error, it will throw at the previous line
const data = await response.json();
if (response.ok) {
return data;
}
// console.log(JSON.stringify(data));
const error = new Error(response?.statusText || data?.message);
error.response = response;
error.data = data;
throw error;
} catch (error) {
if (!error.data) {
error.data = { message: error.message };
}
throw error;
}
}

15
lib_old/session.js Normal file
View file

@ -0,0 +1,15 @@
// this file is a wrapper with defaults to be used in both API routes and `getServerSideProps` functions
import { withIronSessionApiRoute } from 'iron-session/next';
export default function withSession(handler) {
return withIronSessionApiRoute(handler, {
password: process.env.SECRET_COOKIE_PASSWORD,
cookieName: 'weddingwebsitesession',
cookieOptions: {
// the next line allows to use the session in non-https environments like
// Next.js dev mode (http://localhost:3000)
// maxAge default is 14 days
secure: process.env.NODE_ENV === 'production',
},
});
}

35
lib_old/svgs.js Normal file
View file

@ -0,0 +1,35 @@
export const CalendarIcon = () => (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 6.5C11 6.36739 11.0527 6.24021 11.1464 6.14645C11.2402 6.05268 11.3674 6 11.5 6H12.5C12.6326 6 12.7598 6.05268 12.8536 6.14645C12.9473 6.24021 13 6.36739 13 6.5V7.5C13 7.63261 12.9473 7.75979 12.8536 7.85355C12.7598 7.94732 12.6326 8 12.5 8H11.5C11.3674 8 11.2402 7.94732 11.1464 7.85355C11.0527 7.75979 11 7.63261 11 7.5V6.5Z"
fill="#FCCFB9"
/>
<path
d="M3.5 0C3.63261 0 3.75979 0.0526784 3.85355 0.146447C3.94732 0.240215 4 0.367392 4 0.5V1H12V0.5C12 0.367392 12.0527 0.240215 12.1464 0.146447C12.2402 0.0526784 12.3674 0 12.5 0C12.6326 0 12.7598 0.0526784 12.8536 0.146447C12.9473 0.240215 13 0.367392 13 0.5V1H14C14.5304 1 15.0391 1.21071 15.4142 1.58579C15.7893 1.96086 16 2.46957 16 3V14C16 14.5304 15.7893 15.0391 15.4142 15.4142C15.0391 15.7893 14.5304 16 14 16H2C1.46957 16 0.960859 15.7893 0.585786 15.4142C0.210714 15.0391 0 14.5304 0 14V3C0 2.46957 0.210714 1.96086 0.585786 1.58579C0.960859 1.21071 1.46957 1 2 1H3V0.5C3 0.367392 3.05268 0.240215 3.14645 0.146447C3.24021 0.0526784 3.36739 0 3.5 0V0ZM1 4V14C1 14.2652 1.10536 14.5196 1.29289 14.7071C1.48043 14.8946 1.73478 15 2 15H14C14.2652 15 14.5196 14.8946 14.7071 14.7071C14.8946 14.5196 15 14.2652 15 14V4H1Z"
fill="#FCCFB9"
/>
</svg>
);
export const MapIcon = () => (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.817 0.112823C15.8743 0.159759 15.9204 0.218822 15.952 0.285748C15.9837 0.352674 16 0.425792 16 0.499823V14.4998C15.9999 14.6154 15.9598 14.7273 15.8866 14.8167C15.8133 14.906 15.7113 14.9672 15.598 14.9898L10.598 15.9898C10.5333 16.0027 10.4667 16.0027 10.402 15.9898L5.5 15.0098L0.598 15.9898C0.525489 16.0043 0.450665 16.0025 0.378921 15.9846C0.307176 15.9667 0.240296 15.9331 0.183099 15.8863C0.125903 15.8394 0.0798134 15.7804 0.0481518 15.7136C0.0164902 15.6468 4.46527e-05 15.5738 0 15.4998L0 1.49982C6.9782e-05 1.38428 0.0401561 1.27232 0.113443 1.18299C0.186731 1.09366 0.288695 1.03247 0.402 1.00982L5.402 0.00982311C5.46669 -0.00310763 5.53331 -0.00310763 5.598 0.00982311L10.5 0.989823L15.402 0.00982311C15.4745 -0.00476108 15.5493 -0.00308756 15.6211 0.0147231C15.6928 0.0325338 15.7597 0.0660382 15.817 0.112823V0.112823ZM10 1.90982L6 1.10982V14.0898L10 14.8898V1.90982ZM11 14.8898L15 14.0898V1.10982L11 1.90982V14.8898ZM5 14.0898V1.10982L1 1.90982V14.8898L5 14.0898Z"
fill="#FCCFB9"
/>
</svg>
);

71
lib_old/useForm.js Normal file
View file

@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
export default function useForm(initial = {}) {
// Create a state object for our inputs
const [inputs, setInputs] = useState(initial);
const initialValues = Object.values(initial).join('');
useEffect(() => {
// This function runs when the things we are watching change
setInputs(initial);
}, [initialValues]);
function handleChange(e) {
let { value, name, type } = e.target;
console.log(value, name, type);
if (name.includes('_')) {
const values = name.split('_');
// console.log(`Values: ${JSON.stringify(values)}`);
const [id, property] = values;
const data = inputs[id];
// console.log(`Data: ${JSON.stringify(data)}`);
// console.log(`Value: ${JSON.stringify(value)}`);
// console.log(`Property: ${JSON.stringify(property)}`);
data[property] = value;
if (property === 'rsvpStatus' && value !== 'accepted') {
// console.log('Setting plus one to false');
data.plusOne = false;
}
setInputs({
...inputs,
[id]: data,
});
} else {
if (type === 'number') {
value = parseInt(value);
}
if (type === 'file') {
value[0] = e.target.files;
}
setInputs({
// Copy the existing state
...inputs,
[name]: value,
});
}
// console.log(`Inputs after: ${JSON.stringify(inputs)}`);
}
function resetForm() {
setInputs(initial);
}
function clearForm() {
const blankState = Object.fromEntries(
Object.entries(inputs).map(([key, value]) => [key, ''])
);
setInputs(blankState);
}
// Return the things we want to surface from this custom hook
return {
inputs,
handleChange,
resetForm,
clearForm,
};
}

16
lib_old/useModal.js Normal file
View file

@ -0,0 +1,16 @@
import { useState } from 'react';
const useModal = () => {
const [isVisible, setIsVisible] = useState(false);
function toggleModal() {
setIsVisible(!isVisible);
}
return {
isVisible,
toggleModal,
};
};
export default useModal;

27
lib_old/useUser.js Normal file
View file

@ -0,0 +1,27 @@
import { useEffect } from 'react';
import Router from 'next/router';
import useSWR from 'swr';
export default function useUser({
redirectTo = false,
redirectIfFound = false,
} = {}) {
const { data: user, mutate: mutateUser } = useSWR('/api/user');
useEffect(() => {
// if no redirect needed, just return (example: already on /dashboard)
// if user data not yet there (fetch in progress, logged in or not) then don't do anything yet
if (!redirectTo || !user) return;
if (
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && !user?.isLoggedIn) ||
// If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && user?.isLoggedIn)
) {
Router.push(redirectTo);
}
}, [user, redirectIfFound, redirectTo]);
return { user, mutateUser };
}

View file

@ -0,0 +1,17 @@
import { useState } from 'react';
import useInterval from '../utils_old/useInterval';
export default function useWeddingStart({ update = 60000 }) {
const weddingDate = 1906736400000;
const [timeToWedding, setTime] = useState(
weddingDate - Date.now() <= 0 ? 0 : weddingDate - Date.now()
);
useInterval(() => {
const time = weddingDate - Date.now();
setTime(time <= 0 ? 0 : time);
}, update);
return {
timeToWedding,
timeAsDays: Math.ceil(timeToWedding / 1000 / 60 / 60 / 24),
};
}

7
lib_old/utils.js Normal file
View file

@ -0,0 +1,7 @@
export async function copyToClipboard(textToCopy) {
try {
await navigator.clipboard.writeText(textToCopy);
} catch (e) {
console.error(e);
}
}

View file

@ -1,20 +0,0 @@
import mongoose from 'mongoose';
const { Schema } = mongoose;
const groupSchema = new Schema({
name: String,
note: String,
rsvpSubmitted: {
type: Boolean,
deafult: false,
},
guests: [
{
type: Schema.Types.ObjectId,
ref: 'Guest',
},
],
});
export default mongoose.models.Group || mongoose.model('Group', groupSchema);

View file

@ -1,26 +0,0 @@
import mongoose from 'mongoose';
const { Schema } = mongoose;
const guestSchema = new Schema({
firstName: { type: String, required: true },
lastName: { type: String, required: true },
role: String,
rsvpStatus: {
type: String,
enum: ['invited', 'accepted', 'declined'],
default: 'invited',
},
dietaryNotes: String,
songRequests: String,
hasPlusOne: { type: Boolean, default: false },
plusOne: { type: Boolean, default: false },
plusOneFirstName: String,
plusOneLastName: String,
group: {
type: Schema.Types.ObjectId,
ref: 'Group.guests',
},
});
export default mongoose.models.Guest || mongoose.model('Guest', guestSchema);

View file

@ -1,15 +0,0 @@
import mongoose from 'mongoose';
const { Schema } = mongoose;
const UserSchema = new Schema({
username: String,
password: String,
role: {
type: String,
enum: ['admin', 'guest'],
default: 'guest',
},
});
export default mongoose.models.User || mongoose.model('User', UserSchema);

View file

@ -1,8 +1,29 @@
module.exports = {
compiler: {
styledComponents: true,
},
images: {
domains: ['res.cloudinary.com', 'via.placeholder.com', 'picsum.photos'],
},
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
typescript: {
ignoreBuildErrors: true,
},
compiler: {
styledComponents: true,
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "res.cloudinary.com",
},
{
protocol: "https",
hostname: "via.placeholder.com",
},
{
protocol: "https",
hostname: "picsum.photos",
},
],
formats: ["image/avif", "image/webp"],
},
};
export default nextConfig;

23248
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,143 +1,74 @@
{
"name": "weddingsite",
"version": "0.2.0",
"description": "Wedding Website",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--inspect' next dev",
"build": "next build",
"start": "next start",
"test": "NODE_ENV=test jest --watch"
},
"dependencies": {
"@plaiceholder/next": "^2.5.0",
"@radix-ui/react-dialog": "^1.0.2",
"babel-core": "^6.26.3",
"babel-plugin-styled-components": "^2.0.7",
"bcryptjs": "^2.4.3",
"cloudinary-build-url": "^0.2.4",
"dotenv": "^16.0.3",
"escape-html": "^1.0.3",
"html-escaper": "^3.0.3",
"iron-session": "^6.3.1",
"jsonwebtoken": "^9.0.0",
"mongodb": "^4.11.0",
"mongoose": "^6.11.3",
"next": "^13.0.3",
"normalize.css": "^8.0.1",
"nprogress": "^0.2.0",
"plaiceholder": "^2.5.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.6.0",
"sharp": "^0.31.2",
"styled-components": "^5.3.6",
"swr": "^1.3.0",
"waait": "^1.0.5"
},
"devDependencies": {
"@babel/core": "^7.20.2",
"@babel/preset-env": "^7.20.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@typescript-eslint/eslint-plugin": "^5.42.1",
"@typescript-eslint/parser": "^5.42.1",
"eslint": "^8.27.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.5.0",
"eslint-config-wesbos": "^3.1.4",
"eslint-plugin-html": "^7.1.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^29.3.1",
"prettier": "^2.7.1",
"typescript": "^4.8.4"
},
"eslintConfig": {
"extends": [
"wesbos"
],
"rules": {
"react/prop-types": 0
}
},
"jest": {
"testEnvironment": "jsdom",
"setupFiles": [
"./.jest/setEnvVars.js"
],
"setupFilesAfterEnv": [
"./jest.setup.js"
]
},
"//": "This is our babel config, I prefer this over a .babelrc file",
"babel": {
"plugins": [
[
"styled-components",
{
"ssr": true,
"displayName": true,
"preprocess": false
}
]
],
"env": {
"development": {
"presets": [
"next/babel"
],
"plugins": [
[
"styled-components",
{
"ssr": true,
"displayName": true,
"preprocess": false
}
]
]
},
"production": {
"presets": [
"next/babel"
],
"plugins": [
[
"styled-components",
{
"ssr": true,
"displayName": true
}
]
]
},
"test": {
"presets": [
[
"next/babel",
{
"preset-env": {
"modules": "commonjs"
}
}
]
],
"plugins": [
[
"styled-components",
{
"ssr": true,
"displayName": true
}
]
]
}
}
}
"name": "weddingsite",
"version": "0.2.0",
"description": "Wedding Website",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--inspect' next dev",
"build": "next build",
"start": "next start",
"test": "NODE_ENV=test jest --watch",
"db:studio": "prisma studio",
"db:push": "prisma db push",
"db:generate": "prisma generate",
"db:seed": "prisma db seed"
},
"prisma": {
"seed": "node --loader ts-node/esm prisma/seed.ts"
},
"type": "module",
"engines": {
"node": ">=18.0.0 <19.0.0 || >=20.0.0 <21.0.0",
"pnpm": ">=8"
},
"dependencies": {
"@lucia-auth/adapter-postgresql": "^2.0.2",
"@lucia-auth/adapter-prisma": "^3.0.2",
"@plaiceholder/next": "^2.5.0",
"@prisma/client": "^5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-form": "^0.0.3",
"@vercel/postgres": "^0.5.1",
"bcrypt": "^5.1.1",
"bcryptjs": "^2.4.3",
"cloudinary-build-url": "^0.2.4",
"dotenv": "^16.0.3",
"escape-html": "^1.0.3",
"html-escaper": "^3.0.3",
"iron-session": "^6.3.1",
"jsonwebtoken": "^9.0.0",
"lucia": "^2.7.4",
"mongodb": "^4.11.0",
"mongoose": "^6.11.3",
"next": "^14.0.2",
"next-auth": "5.0.0-beta.3",
"normalize.css": "^8.0.1",
"nprogress": "^0.2.0",
"plaiceholder": "^2.5.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.6.0",
"sass": "^1.69.5",
"sharp": "^0.32.4",
"swr": "^1.3.0",
"waait": "^1.0.5",
"zod": "^3.22.4"
},
"devDependencies": {
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/node": "^20.9.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^5.42.1",
"@typescript-eslint/parser": "^5.42.1",
"eslint": "^8.27.0",
"eslint-config-next": "^14.0.2",
"prettier": "^2.7.1",
"prisma": "^5.6.0",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
}
}

View file

@ -9,10 +9,10 @@ import useUser from '../../lib/useUser';
import fetchJson from '../../lib/fetchJson';
import Group from '../../models/Group';
import Guest from '../../models/Guest';
import connectDb from '../../utils/db';
import connectDb from '../../utils_old/db';
import { CalendarIcon, MapIcon } from '../../lib/svgs';
import Modal from '../../components/Modal';
import { handleUmamiEvent } from '../../utils/handleUmamiEvent';
import { handleUmamiEvent } from '../../utils_old/handleUmamiEvent';
const RSVPGroupStyles = styled.div`
h2 {

5648
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

4
postcss.config.cjs Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
plugins: {
},
}

View file

@ -0,0 +1,13 @@
enum Permission {
UPDATE_USER = "UPDATE_USER",
VIEW_USER = "VIEW_USER",
}
export const RolePermissions = {
admin: {
permission: [Permission.UPDATE_USER, Permission.VIEW_USER],
},
guest: {
permission: [Permission.VIEW_USER],
}
}

130
prisma/schema.prisma Normal file
View file

@ -0,0 +1,130 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
}
model User {
id String @id @default(uuid())
username String @unique
roles UserRole[]
auth_session Session[]
key Key[]
created_at DateTime @default(now()) @db.Timestamp(6)
updated_at DateTime @updatedAt @db.Timestamp(6)
@@map("users")
}
enum RSVP_STATUS {
INVITED
ACCEPTED
DECLINED
}
model Guest {
id String @id @default(uuid())
first_name String
last_name String
rsvp_status RSVP_STATUS
dietary_notes String
song_requests String
has_plus_one Boolean @default(false)
plus_one Boolean @default(false)
plus_one_first_name String?
plus_one_last_name String?
group Group @relation(fields: [group_id], references: [id])
group_id String
@@map("guests")
}
model Group {
id String @id @default(uuid())
name String
note String
rsvp_submitted Boolean @default(false)
guests Guest[]
@@map("groups")
}
model Role {
id String @id @default(uuid())
name String @unique
user_roles UserRole[]
role_permissions RolePermission[]
@@map("roles")
}
model UserRole {
id String @id @default(uuid())
user User @relation(fields: [user_id], references: [id])
user_id String
role Role @relation(fields: [role_id], references: [id])
role_id String
created_at DateTime @default(now()) @db.Timestamp(6)
updated_at DateTime @updatedAt @db.Timestamp(6)
@@unique([user_id, role_id])
@@index([user_id])
@@index([role_id])
@@map("user_roles")
}
enum PERMISSION_NAME {
VIEW_USER
MODIFY_USER
}
model Permission {
id String @id @default(uuid())
name PERMISSION_NAME
description String
role_permissions RolePermission[]
@@map("permissions")
}
model RolePermission {
id String @id @default(uuid())
role Role @relation(fields: [role_id], references: [id], onDelete: Cascade)
role_id String
permission Permission @relation(fields: [permission_id], references: [id], onDelete: Cascade)
permission_id String
created_at DateTime @default(now()) @db.Timestamp(6)
updated_at DateTime @updatedAt @db.Timestamp(6)
@@map("role_permissions")
}
model Key {
id String @id @default(uuid())
hashed_password String?
user_id String
user User @relation(references: [id], fields: [user_id], onDelete: Cascade)
@@index([user_id])
@@map("keys")
}
model Session {
id String @id @default(uuid())
user_id String
active_expires BigInt
idle_expires BigInt
user User @relation(references: [id], fields: [user_id], onDelete: Cascade)
created_at DateTime @default(now()) @db.Timestamp(6)
updated_at DateTime @updatedAt @db.Timestamp(6)
@@index([user_id])
@@map("sessions")
}

Some files were not shown because too many files have changed in this diff Show more