├── src ├── __mocks__ │ ├── msw │ │ ├── handlers │ │ │ ├── index.js │ │ │ └── get.ts │ │ └── server.ts │ ├── fileMock.js │ ├── styleMock.js │ └── modules │ │ ├── supabase │ │ ├── utils.js │ │ └── useSupabasePhoto.js │ │ └── next │ │ └── Link.jsx ├── lib │ ├── auth │ │ ├── types.ts │ │ ├── utils.ts │ │ └── useWhitelistUser.ts │ ├── supabase │ │ ├── constants.js │ │ ├── index.ts │ │ ├── hooks │ │ │ └── useSupabasePhoto.ts │ │ └── utils.ts │ ├── axios │ │ ├── constants.ts │ │ └── axiosInstance.ts │ ├── api │ │ ├── constants.ts │ │ └── utils.ts │ ├── react-query │ │ ├── query-keys.ts │ │ ├── query-client.ts │ │ ├── useWillUnmount.ts │ │ └── useHandleError.ts │ ├── colors │ │ └── index.js │ ├── instruments │ │ ├── types.ts │ │ └── index.ts │ ├── venues │ │ ├── types.ts │ │ └── index.ts │ ├── hooks │ │ └── useAsync.ts │ ├── shows │ │ ├── components │ │ │ ├── ShowDate.tsx │ │ │ ├── DeleteShowModal.tsx │ │ │ ├── venues │ │ │ │ ├── EditVenues.tsx │ │ │ │ ├── DeleteVenueModal.tsx │ │ │ │ ├── Venue.tsx │ │ │ │ └── EditVenueModal.tsx │ │ │ ├── EditableShow.tsx │ │ │ ├── Show.tsx │ │ │ ├── ShowsGroup.tsx │ │ │ └── ShowVenue.tsx │ │ ├── types.ts │ │ ├── utils.ts │ │ └── index.ts │ ├── musicians │ │ ├── types.ts │ │ ├── components │ │ │ ├── DeleteMusicianModal.tsx │ │ │ └── instruments │ │ │ │ ├── InstrumentMultiSelect.tsx │ │ │ │ ├── DeleteInstrumentModal.tsx │ │ │ │ ├── EditInstruments.tsx │ │ │ │ └── EditInstrumentModal.tsx │ │ ├── index.ts │ │ └── hooks │ │ │ └── useMusicians.tsx │ ├── photos │ │ ├── components │ │ │ ├── Photos.tsx │ │ │ ├── DeletePhotoModal.tsx │ │ │ └── PhotoThumbnail.tsx │ │ ├── hooks │ │ │ └── usePhoto.tsx │ │ ├── dataManipulation.ts │ │ ├── types.ts │ │ └── index.ts │ ├── prisma.ts │ ├── prisma │ │ ├── queries │ │ │ ├── venues.ts │ │ │ ├── instruments.ts │ │ │ ├── shows.ts │ │ │ ├── photos.ts │ │ │ └── musicians.ts │ │ └── types.ts │ └── more │ │ └── components │ │ └── EmailSignupForm.tsx ├── styles │ └── globals.css ├── prisma │ ├── migrations │ │ ├── 20211001001844_init │ │ │ └── migration.sql │ │ ├── 20211228235907_init │ │ │ └── migration.sql │ │ ├── 20211005204837_init │ │ │ └── migration.sql │ │ ├── migration_lock.toml │ │ ├── 20220108002035_init │ │ │ └── migration.sql │ │ ├── 20210930190403_init │ │ │ └── migration.sql │ │ ├── 20211225002637_init │ │ │ └── migration.sql │ │ ├── 20211005235610_init │ │ │ └── migration.sql │ │ ├── 20211227213910_init │ │ │ └── migration.sql │ │ ├── 20220105202143_init │ │ │ └── migration.sql │ │ ├── 20210930222157_init │ │ │ └── migration.sql │ │ ├── 20220201053200_init │ │ │ └── migration.sql │ │ ├── 20210930201411_init │ │ │ └── migration.sql │ │ ├── 20210930194513_init │ │ │ └── migration.sql │ │ ├── 20220131195244_init │ │ │ └── migration.sql │ │ ├── 20211014183802_init │ │ │ └── migration.sql │ │ └── 20210930184725_init │ │ │ └── migration.sql │ └── schema.prisma ├── components │ ├── Layout │ │ ├── Footer │ │ │ └── index.tsx │ │ ├── index.tsx │ │ └── Navbar │ │ │ ├── SocialLinks.tsx │ │ │ └── NavLink.tsx │ ├── toasts │ │ ├── types.ts │ │ ├── ToastContainer.tsx │ │ ├── useToast.ts │ │ └── ToastContext.tsx │ ├── lib │ │ ├── Style │ │ │ ├── Section.tsx │ │ │ ├── Keyword.tsx │ │ │ ├── Heading.tsx │ │ │ ├── LinkKeyword.tsx │ │ │ ├── InternalLinkKeyword.tsx │ │ │ └── index.tsx │ │ ├── ErrorComponent.tsx │ │ ├── form │ │ │ ├── FieldContainer.tsx │ │ │ ├── TextArea.tsx │ │ │ ├── TextInput.tsx │ │ │ ├── MultiSelectInput.tsx │ │ │ └── PhotoUpload.tsx │ │ ├── Popover.tsx │ │ ├── Button.tsx │ │ └── modals │ │ │ ├── DeleteItemModal.tsx │ │ │ ├── EditItemModal.tsx │ │ │ └── ModalElements.tsx │ └── loading │ │ └── LoadingSpinner.tsx ├── pages │ ├── 404 │ │ └── index.tsx │ ├── api │ │ ├── auth │ │ │ ├── [...auth0].ts │ │ │ └── whitelist.ts │ │ ├── shows │ │ │ ├── upcoming.ts │ │ │ ├── [id].ts │ │ │ └── index.ts │ │ ├── venues │ │ │ ├── index.ts │ │ │ └── [id].ts │ │ ├── instruments │ │ │ ├── index.ts │ │ │ └── [id].ts │ │ ├── photos │ │ │ ├── index.ts │ │ │ └── [id].ts │ │ ├── musicians │ │ │ ├── [id].ts │ │ │ └── index.ts │ │ └── revalidate │ │ │ └── index.ts │ ├── auth │ │ ├── profile.tsx │ │ └── login.tsx │ ├── _document.tsx │ ├── photos │ │ └── index.tsx │ ├── shows │ │ └── index.tsx │ ├── band │ │ └── index.tsx │ ├── press │ │ └── index.tsx │ ├── _error.jsx │ ├── more │ │ └── index.tsx │ ├── _app.tsx │ └── index.tsx ├── __tests__ │ ├── api │ │ ├── jest.setup.ts │ │ ├── tests │ │ │ ├── revalidationAuth.test.ts │ │ │ └── [id].get.test.ts │ │ └── jest.config.js │ └── ui │ │ ├── tests │ │ └── press.test.tsx │ │ ├── jest.config.js │ │ └── jest.setup.ts └── test-utils │ └── index.tsx ├── public ├── nav-banners │ ├── _old │ │ ├── README │ │ ├── about.png │ │ ├── photos.png │ │ ├── shows.png │ │ ├── about-light.png │ │ ├── band-light.png │ │ ├── more-light.png │ │ ├── shows-light.png │ │ ├── photos-light.png │ │ ├── about-light-blur.png │ │ ├── band-light-blur.png │ │ ├── more-light-blur.png │ │ ├── shows-light-blur.png │ │ └── photos-light-blur.png │ ├── band.png │ ├── more.png │ ├── photos.png │ ├── press.png │ ├── shows.png │ ├── band-blur.png │ ├── more-blur.png │ ├── photos-blur.png │ ├── press-blur.png │ └── shows-blur.png ├── favicon.ico ├── logo │ ├── .DS_Store │ ├── logo-shadow.png │ └── logo-shadow-blur.png ├── photos │ └── bomc.jpg ├── robots.txt └── sitemap.xml ├── cypress ├── integration │ ├── continuous-integration │ │ ├── 06 ssg │ │ │ └── server-data-fetching.spec.js │ │ ├── 01 auth │ │ │ └── auth.test.js │ │ ├── 02 nav │ │ │ ├── photo-navigation.test.js │ │ │ └── navigation.test.js │ │ └── 04 photos │ │ │ └── placeholder-photos.test.js │ └── local-only │ │ └── lighthouse │ │ └── lighthouse.spec.js ├── files │ ├── gustopher.jpg │ └── avalanche-of-cheese.jpg └── support │ └── index.js ├── react-datetime-picker.d.ts ├── cypress.json ├── jsconfig.json ├── next-env.d.ts ├── sentry.properties ├── node.tsconfig.json ├── __mocks__ └── http.js ├── TODO-for-production.md ├── git-pre-commit ├── .gitignore ├── .env_template ├── tsconfig.json ├── twind.config.ts ├── sentry.client.config.js ├── sentry.server.config.js ├── jest.setup.js ├── jest.config.js ├── .vscode └── settings.json ├── .env.test.local_template ├── .env.local_template ├── next.config.js ├── .github └── workflows │ └── e2e.yml └── .eslintrc.json /src/__mocks__/msw/handlers/index.js: -------------------------------------------------------------------------------- 1 | export * from "./get"; 2 | -------------------------------------------------------------------------------- /src/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub"; 2 | -------------------------------------------------------------------------------- /public/nav-banners/_old/README: -------------------------------------------------------------------------------- 1 | font: Boogaloo 2 | dimensions: 305x220 3 | -------------------------------------------------------------------------------- /cypress/integration/continuous-integration/06 ssg/server-data-fetching.spec.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/auth/types.ts: -------------------------------------------------------------------------------- 1 | export type WhitelistResponse = { whitelist: Array }; 2 | -------------------------------------------------------------------------------- /react-datetime-picker.d.ts: -------------------------------------------------------------------------------- 1 | declare module "react-datetime-picker/dist/entry.nostyle"; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/logo/.DS_Store -------------------------------------------------------------------------------- /public/photos/bomc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/photos/bomc.jpg -------------------------------------------------------------------------------- /cypress/files/gustopher.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/cypress/files/gustopher.jpg -------------------------------------------------------------------------------- /public/logo/logo-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/logo/logo-shadow.png -------------------------------------------------------------------------------- /public/nav-banners/band.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/band.png -------------------------------------------------------------------------------- /public/nav-banners/more.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/more.png -------------------------------------------------------------------------------- /public/nav-banners/photos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/photos.png -------------------------------------------------------------------------------- /public/nav-banners/press.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/press.png -------------------------------------------------------------------------------- /public/nav-banners/shows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/shows.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /auth/*? 3 | Sitemap: https://www.tencentteacakes.com/sitemap.xml 4 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind screens; 5 | -------------------------------------------------------------------------------- /public/logo/logo-shadow-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/logo/logo-shadow-blur.png -------------------------------------------------------------------------------- /public/nav-banners/_old/about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/about.png -------------------------------------------------------------------------------- /public/nav-banners/_old/photos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/photos.png -------------------------------------------------------------------------------- /public/nav-banners/_old/shows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/shows.png -------------------------------------------------------------------------------- /public/nav-banners/band-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/band-blur.png -------------------------------------------------------------------------------- /public/nav-banners/more-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/more-blur.png -------------------------------------------------------------------------------- /public/nav-banners/photos-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/photos-blur.png -------------------------------------------------------------------------------- /public/nav-banners/press-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/press-blur.png -------------------------------------------------------------------------------- /public/nav-banners/shows-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/shows-blur.png -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "chromeWebSecurity": false, 3 | "retries": { 4 | "runMode": 2, 5 | "openMode": 1 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/prisma/migrations/20211001001844_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Venue" ALTER COLUMN "url" DROP NOT NULL; 3 | -------------------------------------------------------------------------------- /cypress/files/avalanche-of-cheese.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/cypress/files/avalanche-of-cheese.jpg -------------------------------------------------------------------------------- /public/nav-banners/_old/about-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/about-light.png -------------------------------------------------------------------------------- /public/nav-banners/_old/band-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/band-light.png -------------------------------------------------------------------------------- /public/nav-banners/_old/more-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/more-light.png -------------------------------------------------------------------------------- /public/nav-banners/_old/shows-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/shows-light.png -------------------------------------------------------------------------------- /public/nav-banners/_old/photos-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/photos-light.png -------------------------------------------------------------------------------- /src/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | // Mock CSS files. 2 | // If you're using CSS modules, this file can be deleted. 3 | 4 | module.exports = {}; 5 | -------------------------------------------------------------------------------- /public/nav-banners/_old/about-light-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/about-light-blur.png -------------------------------------------------------------------------------- /public/nav-banners/_old/band-light-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/band-light-blur.png -------------------------------------------------------------------------------- /public/nav-banners/_old/more-light-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/more-light-blur.png -------------------------------------------------------------------------------- /public/nav-banners/_old/shows-light-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/shows-light-blur.png -------------------------------------------------------------------------------- /public/nav-banners/_old/photos-light-blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bonnie/ten-cent-teacakes/HEAD/public/nav-banners/_old/photos-light-blur.png -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/components/*": ["components/*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/prisma/migrations/20211228235907_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "Show_url_key"; 3 | 4 | -- DropIndex 5 | DROP INDEX "Venue_url_key"; 6 | -------------------------------------------------------------------------------- /src/prisma/migrations/20211005204837_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Photo" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /src/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // NOTE: This file should not be edited 4 | // see https://nextjs.org/docs/basic-features/typescript for more information. 5 | -------------------------------------------------------------------------------- /src/__mocks__/modules/supabase/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | esModule: true, 3 | uploadPhotoToSupabase: jest.fn(), 4 | uploadPhotoAndThumbnailToSupabase: jest.fn(), 5 | }; 6 | -------------------------------------------------------------------------------- /src/__mocks__/msw/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from "msw/node"; 2 | 3 | import { getHandlers } from "./handlers"; 4 | 5 | export const server = setupServer(...getHandlers); 6 | -------------------------------------------------------------------------------- /src/prisma/migrations/20220108002035_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Photo" ADD COLUMN "description" VARCHAR(255), 3 | ADD COLUMN "takenAt" TIMESTAMP(3); 4 | -------------------------------------------------------------------------------- /sentry.properties: -------------------------------------------------------------------------------- 1 | defaults.url=https://sentry.io/ 2 | defaults.org=ten-cent-teacakes 3 | defaults.project=ten-cent-teacakes 4 | cli.executable=../../.npm/_npx/a8388072043b4cbc/node_modules/@sentry/cli/bin/sentry-cli 5 | -------------------------------------------------------------------------------- /src/lib/supabase/constants.js: -------------------------------------------------------------------------------- 1 | export const BAND_PHOTOS_BUCKET = "band-photos"; 2 | export const UPLOADS_BUCKET = "uploads"; 3 | export const PHOTOS_DIRNAME = "photos"; 4 | export const MUSICIANS_DIRNAME = "musicians"; 5 | -------------------------------------------------------------------------------- /src/__mocks__/modules/supabase/useSupabasePhoto.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | esModule: true, 3 | getSignedStorageUrl: jest.fn(), 4 | useSupabasePhoto: jest.fn().mockImplementation((path) => ({ imgSrc: path })), 5 | }; 6 | -------------------------------------------------------------------------------- /src/lib/axios/constants.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | export enum routes { 3 | shows = "shows", 4 | musicians = "musicians", 5 | photos = "photos", 6 | venues = "venues", 7 | instruments = "instruments", 8 | } 9 | -------------------------------------------------------------------------------- /node.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ESNext", 5 | "target": "ES2017" 6 | }, 7 | "ts-node": { 8 | "compilerOptions": { 9 | "module": "CommonJS" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/api/constants.ts: -------------------------------------------------------------------------------- 1 | export const uploadDestination = "public/uploads"; 2 | 3 | export const revalidationRoutes = { 4 | shows: [], 5 | venues: [], 6 | musicians: ["/band"], 7 | instruments: ["/band"], 8 | photos: ["/photos", "/"], 9 | }; 10 | -------------------------------------------------------------------------------- /src/lib/react-query/query-keys.ts: -------------------------------------------------------------------------------- 1 | export const queryKeys = { 2 | shows: "shows", 3 | photos: "photos", 4 | musicians: "musicians", 5 | venues: "venues", 6 | instruments: "instruments", 7 | whitelist: "whitelist", 8 | sendEmail: "send-email", 9 | }; 10 | -------------------------------------------------------------------------------- /__mocks__/http.js: -------------------------------------------------------------------------------- 1 | import http, { ServerResponse } from "http"; 2 | 3 | // mock unstable_revalidate, as we don't want that to run during jest tests 4 | module.exports = { 5 | esModule: true, 6 | ...http, 7 | ServerResponse: { ...ServerResponse, unstable_revalidate: jest.fn() }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/prisma/migrations/20210930190403_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `bio` to the `Musician` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Musician" ADD COLUMN "bio" TEXT NOT NULL; 9 | -------------------------------------------------------------------------------- /src/prisma/migrations/20211225002637_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[url]` on the table `Venue` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX "Venue_url_key" ON "Venue"("url"); 9 | -------------------------------------------------------------------------------- /src/prisma/migrations/20211005235610_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `photographer` to the `Photo` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Photo" ADD COLUMN "photographer" VARCHAR(255) NOT NULL; 9 | -------------------------------------------------------------------------------- /src/components/Layout/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import React from "react"; 3 | import { tw } from "twind"; 4 | 5 | import { Section } from "@/components/lib/Style/Section"; 6 | 7 | export const Footer: React.FC = () => ( 8 |
© {dayjs().year()}
9 | ); 10 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...auth0].ts: -------------------------------------------------------------------------------- 1 | // adapted from https://auth0.com/blog/ultimate-guide-nextjs-authentication-auth0/ 2 | 3 | import { handleAuth, handleLogin } from "@auth0/nextjs-auth0"; 4 | 5 | export default handleAuth({ 6 | async login(req, res) { 7 | // { returnTo?: string;} 8 | await handleLogin(req, res); 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/pages/auth/profile.tsx: -------------------------------------------------------------------------------- 1 | import { useUser } from "@auth0/nextjs-auth0"; 2 | 3 | import { Heading } from "@/components/lib/Style/Heading"; 4 | 5 | export default function Profile() { 6 | const { user } = useUser(); 7 | return ( 8 |
9 | Your Profile 10 |

You are {user?.email}

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /TODO-for-production.md: -------------------------------------------------------------------------------- 1 | 1. Update Auth0 login cosmetics https://manage.auth0.com/dashboard/us/dev-3x0kqyif/login_settings 2 | 1. Update Auth0 details in dashboard https://manage.auth0.com/dashboard/us/dev-3x0kqyif/applications/uJv5WBjbxuaNEAejUmtsSJT7AyTkQsrq/settings 3 | 1. Check that auth0 rules still apply to free plan: https://manage.auth0.com/dashboard/us/dev-3x0kqyif/rules 4 | -------------------------------------------------------------------------------- /src/lib/react-query/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "react-query"; 2 | 3 | export const defaultQueryClientOptions = { 4 | queries: { 5 | refetchOnWindowFocus: false, 6 | refetchOnReconnect: false, 7 | }, 8 | }; 9 | 10 | export const createQueryClient = () => 11 | new QueryClient({ 12 | defaultOptions: defaultQueryClientOptions, 13 | }); 14 | -------------------------------------------------------------------------------- /src/lib/colors/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | aqua: { 3 | 100: "rgb(239, 245, 245)", 4 | 200: "rgb(207, 226, 226)", 5 | 300: "rgb(175, 207, 207)", 6 | 400: "rgb(143, 188, 188)", 7 | 500: "rgb(111, 169, 169)", 8 | 600: "rgb(86, 144, 144)", 9 | 700: "rgb(67, 112, 112)", 10 | 800: "rgb(48, 80, 80)", 11 | 900: "rgb(29, 48, 48)", 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/prisma/migrations/20211227213910_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[url]` on the table `Show` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Show" ADD COLUMN "url" VARCHAR(255); 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "Show_url_key" ON "Show"("url"); 12 | -------------------------------------------------------------------------------- /src/prisma/migrations/20220105202143_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Photo" DROP CONSTRAINT "Photo_showId_fkey"; 3 | 4 | -- AlterTable 5 | ALTER TABLE "Photo" ALTER COLUMN "photographer" DROP NOT NULL, 6 | ALTER COLUMN "showId" DROP NOT NULL; 7 | 8 | -- AddForeignKey 9 | ALTER TABLE "Photo" ADD CONSTRAINT "Photo_showId_fkey" FOREIGN KEY ("showId") REFERENCES "Show"("id") ON DELETE SET NULL ON UPDATE CASCADE; 10 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://www.tencentteacakes.com/ 5 | 6 | 7 | https://www.tencentteacakes.com/photos 8 | 9 | 10 | https://www.tencentteacakes.com/band 11 | 12 | 13 | https://www.tencentteacakes.com/more 14 | 15 | -------------------------------------------------------------------------------- /src/components/toasts/types.ts: -------------------------------------------------------------------------------- 1 | export type ToastStatus = "success" | "info" | "warning" | "error"; 2 | 3 | export type Toast = { 4 | status: ToastStatus; 5 | message: string; 6 | id: string; 7 | }; 8 | 9 | export type AddToastAction = { 10 | type: "ADD_TOAST"; 11 | toast: Toast; 12 | }; 13 | 14 | export type DeleteToastAction = { 15 | type: "DELETE_TOAST"; 16 | id: string; 17 | }; 18 | 19 | export type ToastAction = AddToastAction | DeleteToastAction; 20 | -------------------------------------------------------------------------------- /src/pages/api/shows/upcoming.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import prisma from "@/lib/prisma"; 4 | 5 | // GET /api/shows/upcoming 6 | export default async function handle( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | const shows = await prisma.show.findMany({ 11 | take: 3, 12 | where: { performAt: { gte: new Date() } }, 13 | orderBy: { performAt: "desc" }, 14 | }); 15 | res.json(shows); 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/404/index.tsx: -------------------------------------------------------------------------------- 1 | import { tw } from "twind"; 2 | 3 | import { Heading } from "@/components/lib/Style/Heading"; 4 | import { LinkKeyword } from "@/components/lib/Style/LinkKeyword"; 5 | 6 | const NotFound = () => ( 7 |
8 | Aw, crumbs 9 |

This page does not exist.

10 | Return home 11 |
12 | ); 13 | 14 | export default NotFound; 15 | -------------------------------------------------------------------------------- /git-pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Redirect output to stderr. 4 | exec 1>&2 5 | 6 | echo "---------> 🧹 Running lint pre-commit check" 7 | npm run lint || { 8 | # exited with nonzero code 9 | echo "‼️ Linting failed, not committing ‼️" 10 | exit 1 11 | } 12 | 13 | echo "---------> 💅 Running ts pre-commit check" 14 | npm run ts-check || { 15 | # exited with nonzero code 16 | echo "‼️ TS checks failed, not committing ‼️" 17 | exit 1 18 | } 19 | 20 | echo "✨ no issues found! ✨" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # Keep environment variables out of version control 3 | .env 4 | .env.local 5 | .env.migrate 6 | .env.production 7 | .env.test.local 8 | 9 | .next 10 | .DS_Store 11 | 12 | public/uploads 13 | 14 | # Sentry 15 | .sentryclirc 16 | 17 | # ts checks 18 | tsconfig.tsbuildinfo 19 | 20 | .vercel 21 | 22 | # Cypress videos and screenshots 23 | cypress/videos/ 24 | cypress/screenshots/ 25 | cypress/reports 26 | cypress/logs/ 27 | 28 | # `next export` out directory 29 | /out 30 | -------------------------------------------------------------------------------- /src/components/lib/Style/Section.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | import { mergeClasses, StyleComponentType } from "."; 5 | 6 | export const Section: StyleComponentType = ({ children, className = "" }) => { 7 | const baseClasses = [ 8 | "mt-10", 9 | "w-screen", 10 | "pt-4", 11 | "border-t-8", 12 | "border-dotted", 13 | ]; 14 | return ( 15 |
{children}
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/auth/utils.ts: -------------------------------------------------------------------------------- 1 | import { WhitelistResponse } from "@/lib/auth/types"; 2 | import { axiosInstance } from "@/lib/axios/axiosInstance"; 3 | 4 | export const validateUser = async (userEmail: string) => { 5 | const { data } = await axiosInstance.get( 6 | "/api/auth/whitelist", 7 | ); 8 | const userIsValid = 9 | data.whitelist.length === 0 || data.whitelist.includes(userEmail); 10 | if (!userIsValid) { 11 | window.location.replace("/api/auth/logout"); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/instruments/types.ts: -------------------------------------------------------------------------------- 1 | import { Instrument } from ".prisma/client"; 2 | 3 | export type InstrumentResponse = { instrument: Instrument }; 4 | 5 | export type InstrumentPutData = { name: string }; 6 | 7 | export type InstrumentPatchData = { 8 | data: InstrumentPutData; 9 | id: number; 10 | }; 11 | 12 | export type InstrumentPatchArgs = { 13 | id: number; 14 | data: InstrumentPutData; 15 | }; 16 | 17 | export type InstrumentWithMusicianCount = Instrument & { 18 | musicianCount: number; 19 | }; 20 | -------------------------------------------------------------------------------- /.env_template: -------------------------------------------------------------------------------- 1 | # This was inserted by `prisma init`: 2 | 3 | # Environment variables declared in this file are automatically made available to Prisma. 4 | 5 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#using-environment-variables 6 | 7 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server (Preview) and MongoDB (Preview). 8 | 9 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 10 | 11 | DATABASE_URL= 12 | -------------------------------------------------------------------------------- /src/components/lib/ErrorComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | import { Heading } from "@/components/lib/Style/Heading"; 5 | import { LinkKeyword } from "@/components/lib/Style/LinkKeyword"; 6 | 7 | export const ErrorComponent = () => ( 8 |
9 | Aw, crumbs 10 |

An error occurred, and our team has been notified.

11 | Return home 12 |
13 | ); 14 | -------------------------------------------------------------------------------- /src/lib/venues/types.ts: -------------------------------------------------------------------------------- 1 | import { Venue } from ".prisma/client"; 2 | 3 | export type VenueWithShowCount = Venue & { showCount: number }; 4 | export type VenueResponse = { venue: VenueWithShowCount }; 5 | 6 | export type VenuePutData = { name: string; url?: string }; 7 | 8 | export type VenuePatchData = { 9 | data: VenuePutData; 10 | id: number; 11 | }; 12 | 13 | export type VenuePatchArgs = { 14 | id: number; 15 | data: VenuePutData; 16 | }; 17 | 18 | export type VenuePatchResponse = { 19 | venue: Venue; 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/lib/Style/Keyword.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | StyleComponent, 5 | styleComponentDefaultProps, 6 | StyleComponentType, 7 | } from "."; 8 | 9 | export const Keyword: StyleComponentType = ({ children, className = "" }) => { 10 | const baseClasses = ["font-bold", "text-aqua-700", "inline"]; 11 | 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | }; 18 | 19 | Keyword.defaultProps = styleComponentDefaultProps; 20 | -------------------------------------------------------------------------------- /src/lib/hooks/useAsync.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | // from https://stackoverflow.com/questions/53949393/cant-perform-a-react-state-update-on-an-unmounted-component/60907638#60907638 4 | export function useAsync( 5 | asyncFn: () => Promise, 6 | onSuccess: (data: PromiseType) => void, 7 | ) { 8 | useEffect(() => { 9 | let isActive = true; 10 | asyncFn().then((data) => { 11 | if (isActive) onSuccess(data); 12 | }); 13 | return () => { 14 | isActive = false; 15 | }; 16 | }, [asyncFn, onSuccess]); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/supabase/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/nextjs"; 2 | import { createClient } from "@supabase/supabase-js"; 3 | 4 | if (!process.env.SUPABASE_URL) { 5 | const error = new Error("Missing process.env.SUPABASE_URL"); 6 | Sentry.captureException(error); 7 | throw error; 8 | } 9 | if (!process.env.SUPABASE_KEY) { 10 | const error = new Error("Missing process.env.SUPABASE_KEY"); 11 | Sentry.captureException(error); 12 | throw error; 13 | } 14 | export const supabase = createClient( 15 | process.env.SUPABASE_URL, 16 | process.env.SUPABASE_KEY, 17 | ); 18 | -------------------------------------------------------------------------------- /src/lib/react-query/useWillUnmount.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export function useWillUnmount(fn: () => void) { 4 | // the ref makes it so the fn doesn't have to be a dependency in the useEffect 5 | const fnRef = useRef(fn); 6 | 7 | // make sure the ref is updated if the fn is updated 8 | fnRef.current = fn; 9 | 10 | useEffect(() => () => fnRef.current(), []); 11 | 12 | const isMountedRef = useRef(true); 13 | useEffect( 14 | () => () => { 15 | isMountedRef.current = false; 16 | }, 17 | [], 18 | ); 19 | return { isMountedRef }; 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/api/shows/[id].ts: -------------------------------------------------------------------------------- 1 | import { revalidationRoutes } from "@/lib/api/constants"; 2 | import { 3 | addStandardDelete, 4 | addStandardPatch, 5 | createHandler, 6 | } from "@/lib/api/handler"; 7 | import { deleteShow, patchShow } from "@/lib/prisma/queries/shows"; 8 | 9 | const handler = createHandler(revalidationRoutes.shows); 10 | addStandardDelete({ 11 | handler, 12 | deleteFunc: deleteShow, 13 | revalidationRoutes: [], 14 | }); 15 | 16 | addStandardPatch({ 17 | handler, 18 | patchFunc: patchShow, 19 | revalidationRoutes: revalidationRoutes.shows, 20 | }); 21 | 22 | export default handler; 23 | -------------------------------------------------------------------------------- /src/pages/api/shows/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { revalidationRoutes } from "@/lib/api/constants"; 4 | import { addStandardPut, createHandler } from "@/lib/api/handler"; 5 | import { addShow, getShows } from "@/lib/prisma/queries/shows"; 6 | 7 | const handler = createHandler(revalidationRoutes.shows); 8 | handler.get(async (req: NextApiRequest, res: NextApiResponse) => 9 | res.json(await getShows()), 10 | ); 11 | 12 | addStandardPut({ 13 | handler, 14 | addFunc: addShow, 15 | revalidationRoutes: revalidationRoutes.shows, 16 | }); 17 | 18 | export default handler; 19 | -------------------------------------------------------------------------------- /src/components/lib/Style/Heading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | type HeadingProps = { 5 | textSize?: string; 6 | align?: "left" | "right" | "center"; 7 | margin?: number; 8 | }; 9 | 10 | export const Heading: React.FC> = ({ 11 | children, 12 | textSize, 13 | align, 14 | margin, 15 | }) => { 16 | const classes = tw`font-display text-${textSize} m-${margin} text-${align}`; 17 | return

{children}

; 18 | }; 19 | 20 | Heading.defaultProps = { 21 | textSize: "6xl", 22 | align: "center", 23 | margin: 5, 24 | }; 25 | -------------------------------------------------------------------------------- /src/__mocks__/modules/next/Link.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | /* eslint-disable react/display-name */ 3 | // to avoid "not wrapped in act" error 4 | // https://github.com/vercel/next.js/issues/20048#issuecomment-869190879 5 | 6 | // this doesn't work; get error about "Element type is invalid" 7 | // module.exports = { 8 | // __esModule: true, 9 | // default: ({ children, href }) => ( 10 | // 11 | // ), 12 | // }; 13 | 14 | // eslint-disable-next-line jsx-a11y/anchor-has-content 15 | export default ({ children, href }) => {children}; 16 | -------------------------------------------------------------------------------- /src/pages/api/venues/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { revalidationRoutes } from "@/lib/api/constants"; 4 | import { addStandardPut, createHandler } from "@/lib/api/handler"; 5 | import { addVenue, getVenues } from "@/lib/prisma/queries/venues"; 6 | 7 | const handler = createHandler(revalidationRoutes.venues); 8 | handler.get(async (req: NextApiRequest, res: NextApiResponse) => 9 | res.json(await getVenues()), 10 | ); 11 | 12 | addStandardPut({ 13 | handler, 14 | addFunc: addVenue, 15 | revalidationRoutes: revalidationRoutes.venues, 16 | }); 17 | 18 | export default handler; 19 | -------------------------------------------------------------------------------- /src/lib/shows/components/ShowDate.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import React from "react"; 3 | import { tw } from "twind"; 4 | 5 | export const ShowDate: React.FC<{ performAt: Date }> = ({ performAt }) => ( 6 |
15 |
16 | {dayjs(performAt).format("MMM D, YYYY")} 17 |
18 |
19 | {dayjs(performAt).format("h:mm a")} 20 |
21 |
22 | ); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["src/*"] 8 | }, 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/api/instruments/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { revalidationRoutes } from "@/lib/api/constants"; 4 | import { addStandardPut, createHandler } from "@/lib/api/handler"; 5 | import { 6 | addInstrument, 7 | getInstruments, 8 | } from "@/lib/prisma/queries/instruments"; 9 | 10 | const handler = createHandler(revalidationRoutes.instruments); 11 | handler.get(async (req: NextApiRequest, res: NextApiResponse) => 12 | res.json(await getInstruments()), 13 | ); 14 | addStandardPut({ 15 | handler, 16 | addFunc: addInstrument, 17 | revalidationRoutes: revalidationRoutes.instruments, 18 | }); 19 | 20 | export default handler; 21 | -------------------------------------------------------------------------------- /src/pages/api/photos/index.ts: -------------------------------------------------------------------------------- 1 | import { withSentry } from "@sentry/nextjs"; 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | import { revalidationRoutes } from "@/lib/api/constants"; 5 | import { addStandardPut, createHandler } from "@/lib/api/handler"; 6 | import { addPhoto, getPhotos } from "@/lib/prisma/queries/photos"; 7 | 8 | const handler = createHandler(revalidationRoutes.photos); 9 | handler.get(async (req: NextApiRequest, res: NextApiResponse) => 10 | res.json(await getPhotos()), 11 | ); 12 | addStandardPut({ 13 | handler, 14 | addFunc: addPhoto, 15 | revalidationRoutes: revalidationRoutes.photos, 16 | }); 17 | 18 | export default withSentry(handler); 19 | -------------------------------------------------------------------------------- /src/pages/api/instruments/[id].ts: -------------------------------------------------------------------------------- 1 | import { revalidationRoutes } from "@/lib/api/constants"; 2 | import { 3 | addStandardDelete, 4 | addStandardPatch, 5 | createHandler, 6 | } from "@/lib/api/handler"; 7 | import { 8 | deleteInstrument, 9 | patchInstrument, 10 | } from "@/lib/prisma/queries/instruments"; 11 | 12 | const handler = createHandler(revalidationRoutes.instruments); 13 | addStandardDelete({ 14 | handler, 15 | deleteFunc: deleteInstrument, 16 | revalidationRoutes: revalidationRoutes.instruments, 17 | }); 18 | addStandardPatch({ 19 | handler, 20 | patchFunc: patchInstrument, 21 | revalidationRoutes: revalidationRoutes.instruments, 22 | }); 23 | 24 | export default handler; 25 | -------------------------------------------------------------------------------- /src/lib/musicians/types.ts: -------------------------------------------------------------------------------- 1 | import { Instrument, Musician } from ".prisma/client"; 2 | 3 | export type InstrumentName = { name: string }; 4 | export type MusicianWithInstruments = Musician & { 5 | instruments: Array; 6 | }; 7 | 8 | export type MusicianResponse = { 9 | musician: Musician; 10 | }; 11 | 12 | export type MusicianFormData = { 13 | imageFile?: File; 14 | firstName?: string; 15 | lastName?: string; 16 | bio?: string; 17 | instrumentIds?: Array; 18 | imagePath?: string; 19 | }; 20 | 21 | export type MusicianPutData = MusicianFormData & { 22 | imagePath?: string; 23 | }; 24 | 25 | export type MusicianPatchArgs = { 26 | id: number; 27 | data: MusicianPutData; 28 | }; 29 | -------------------------------------------------------------------------------- /src/lib/shows/types.ts: -------------------------------------------------------------------------------- 1 | import { Show, Venue } from ".prisma/client"; 2 | 3 | export type ShowWithVenue = Show & { 4 | venue: Venue; 5 | }; 6 | 7 | export type ShowFormData = { 8 | venueId: number | undefined; 9 | performDate: string; 10 | performTime: string; 11 | url?: string; 12 | }; 13 | 14 | export type ShowPutData = { performAt: Date; venueId: number; url?: string }; 15 | 16 | export type ShowPatchData = { 17 | performAt?: Date; 18 | venueId?: number; 19 | url?: string; 20 | }; 21 | export type ShowPatchArgs = { 22 | id: number; 23 | data: ShowPatchData; 24 | }; 25 | 26 | export type SortedShows = { 27 | upcomingShows: Array; 28 | pastShows: Array; 29 | }; 30 | -------------------------------------------------------------------------------- /src/pages/api/photos/[id].ts: -------------------------------------------------------------------------------- 1 | import { revalidationRoutes } from "@/lib/api/constants"; 2 | import { 3 | addStandardDelete, 4 | addStandardGetById, 5 | addStandardPatch, 6 | createHandler, 7 | } from "@/lib/api/handler"; 8 | import { 9 | deletePhoto, 10 | getPhotoById, 11 | patchPhoto, 12 | } from "@/lib/prisma/queries/photos"; 13 | 14 | const handler = createHandler(revalidationRoutes.photos); 15 | addStandardDelete({ handler, deleteFunc: deletePhoto }); 16 | addStandardGetById({ 17 | handler, 18 | getByIdFunc: getPhotoById, 19 | }); 20 | addStandardPatch({ 21 | handler, 22 | patchFunc: patchPhoto, 23 | revalidationRoutes: revalidationRoutes.photos, 24 | }); 25 | 26 | export default handler; 27 | -------------------------------------------------------------------------------- /src/prisma/migrations/20210930222157_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `name` on the `Musician` table. All the data in the column will be lost. 5 | - Added the required column `firstName` to the `Musician` table without a default value. This is not possible if the table is not empty. 6 | - Added the required column `lastName` to the `Musician` table without a default value. This is not possible if the table is not empty. 7 | 8 | */ 9 | -- DropIndex 10 | DROP INDEX "Musician_name_key"; 11 | 12 | -- AlterTable 13 | ALTER TABLE "Musician" DROP COLUMN "name", 14 | ADD COLUMN "firstName" VARCHAR(255) NOT NULL, 15 | ADD COLUMN "lastName" VARCHAR(255) NOT NULL; 16 | -------------------------------------------------------------------------------- /src/pages/api/musicians/[id].ts: -------------------------------------------------------------------------------- 1 | import { withSentry } from "@sentry/nextjs"; 2 | 3 | import { revalidationRoutes } from "@/lib/api/constants"; 4 | import { 5 | addStandardDelete, 6 | addStandardPatch, 7 | createHandler, 8 | } from "@/lib/api/handler"; 9 | import { deleteMusician, patchMusician } from "@/lib/prisma/queries/musicians"; 10 | 11 | const handler = createHandler(revalidationRoutes.musicians); 12 | 13 | addStandardDelete({ 14 | handler, 15 | deleteFunc: deleteMusician, 16 | revalidationRoutes: revalidationRoutes.musicians, 17 | }); 18 | addStandardPatch({ 19 | handler, 20 | patchFunc: patchMusician, 21 | revalidationRoutes: revalidationRoutes.musicians, 22 | }); 23 | 24 | export default withSentry(handler); 25 | -------------------------------------------------------------------------------- /twind.config.ts: -------------------------------------------------------------------------------- 1 | import twind from "twind"; 2 | 3 | const themeColors = require("./src/lib/colors"); 4 | const colors = require("tailwindcss/colors"); 5 | const forms: twind.Plugin = require("@twind/forms"); 6 | 7 | const config = { 8 | theme: { 9 | colors: { 10 | aqua: themeColors.aqua, 11 | black: colors.black, 12 | white: colors.white, 13 | gray: colors.gray, 14 | green: colors.green, 15 | red: colors.red, 16 | yellow: colors.yellow, 17 | blue: colors.blue, 18 | transparent: "transparent", 19 | }, 20 | fontFamily: { 21 | display: "Delfina", 22 | body: "Ubuntu", 23 | }, 24 | }, 25 | plugins: { 26 | forms, 27 | }, 28 | }; 29 | 30 | export default config; 31 | -------------------------------------------------------------------------------- /src/lib/axios/axiosInstance.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from "axios"; 2 | 3 | const config: AxiosRequestConfig = {}; 4 | 5 | if (process.env.NODE_ENV === "test") { 6 | config.baseURL = "http://localhost:3000/"; 7 | } 8 | 9 | // for canceling requests to avoid test errors 10 | export const cancelTokenSource = axios.CancelToken.source(); 11 | if (process.env.NODE === "test") { 12 | config.cancelToken = cancelTokenSource.token; 13 | } 14 | 15 | export const axiosInstance = axios.create(config); 16 | 17 | // for revalidation routes 18 | const revalidationConfig = { 19 | ...config, 20 | params: { 21 | secret: process.env.REVALIDATION_SECRET, 22 | }, 23 | }; 24 | 25 | export const revalidationAxiosInstance = axios.create(revalidationConfig); 26 | -------------------------------------------------------------------------------- /sentry.client.config.js: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the browser. 2 | // The config you add here will be used whenever a page is visited. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 8 | 9 | Sentry.init({ 10 | dsn: SENTRY_DSN, 11 | // Adjust this value in production, or use tracesSampler for greater control 12 | tracesSampleRate: 1.0, 13 | // ... 14 | // Note: if you want to override the automatic release value, do not set a 15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 16 | // that it will also get attached to your source maps 17 | }); 18 | -------------------------------------------------------------------------------- /src/pages/api/musicians/index.ts: -------------------------------------------------------------------------------- 1 | import { withSentry } from "@sentry/nextjs"; 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | import { revalidationRoutes } from "@/lib/api/constants"; 5 | import { addStandardPut, createHandler } from "@/lib/api/handler"; 6 | import { 7 | addMusician, 8 | getMusiciansSortAscending, 9 | } from "@/lib/prisma/queries/musicians"; 10 | 11 | const handler = createHandler(revalidationRoutes.musicians); 12 | handler.get(async (req: NextApiRequest, res: NextApiResponse) => { 13 | res.json(await getMusiciansSortAscending()); 14 | }); 15 | 16 | addStandardPut({ 17 | handler, 18 | addFunc: addMusician, 19 | revalidationRoutes: revalidationRoutes.musicians, 20 | }); 21 | 22 | export default withSentry(handler); 23 | -------------------------------------------------------------------------------- /src/lib/musicians/components/DeleteMusicianModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { DeleteItemModal } from "@/components/lib/modals/DeleteItemModal"; 4 | import { MusicianWithInstruments } from "@/lib/musicians/types"; 5 | 6 | import { useMusicians } from "../hooks/useMusicians"; 7 | 8 | export const DeleteMusicianModal: React.FC<{ 9 | musician: MusicianWithInstruments; 10 | }> = ({ musician }) => { 11 | const { deleteMusician } = useMusicians(); 12 | const description = `Delete musician ${musician.firstName} ${musician.lastName}?`; 13 | return ( 14 | deleteMusician(musician.id)} 18 | /> 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /sentry.server.config.js: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 8 | 9 | Sentry.init({ 10 | dsn: SENTRY_DSN, 11 | // Adjust this value in production, or use tracesSampler for greater control 12 | tracesSampleRate: 1.0, 13 | // ... 14 | // Note: if you want to override the automatic release value, do not set a 15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 16 | // that it will also get attached to your source maps 17 | }); 18 | -------------------------------------------------------------------------------- /src/prisma/migrations/20220201053200_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `imagePixelHeight` on the `Musician` table. All the data in the column will be lost. 5 | - You are about to drop the column `imagePixelWidth` on the `Musician` table. All the data in the column will be lost. 6 | - You are about to drop the column `pixelHeight` on the `Photo` table. All the data in the column will be lost. 7 | - You are about to drop the column `pixelWidth` on the `Photo` table. All the data in the column will be lost. 8 | 9 | */ 10 | -- AlterTable 11 | ALTER TABLE "Musician" DROP COLUMN "imagePixelHeight", 12 | DROP COLUMN "imagePixelWidth"; 13 | 14 | -- AlterTable 15 | ALTER TABLE "Photo" DROP COLUMN "pixelHeight", 16 | DROP COLUMN "pixelWidth"; 17 | -------------------------------------------------------------------------------- /src/components/lib/Style/LinkKeyword.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | mergeClasses, 5 | styleComponentDefaultProps, 6 | StyleComponentProps, 7 | } from "."; 8 | import { Keyword } from "./Keyword"; 9 | 10 | type LinkKeywordProps = StyleComponentProps & { 11 | href: string; 12 | }; 13 | 14 | export const LinkKeyword: React.FC = ({ 15 | children, 16 | className = "", 17 | href, 18 | }) => { 19 | const baseClasses = ["hover:text-aqua-500", "hover:cursor-pointer"]; 20 | return ( 21 | 22 | 23 | {children} 24 | 25 | 26 | ); 27 | }; 28 | 29 | LinkKeyword.defaultProps = styleComponentDefaultProps; 30 | -------------------------------------------------------------------------------- /src/lib/shows/components/DeleteShowModal.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import React from "react"; 3 | 4 | import { DeleteItemModal } from "@/components/lib/modals/DeleteItemModal"; 5 | import { ShowWithVenue } from "@/lib/shows/types"; 6 | 7 | import { useShows } from "../hooks/useShows"; 8 | 9 | export const DeleteShowModal: React.FC<{ show: ShowWithVenue }> = ({ 10 | show, 11 | }) => { 12 | const { deleteShow } = useShows(); 13 | const description = `Delete show at ${show.venue.name}?`; 14 | return ( 15 | deleteShow(show.id)} 21 | /> 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/pages/api/venues/[id].ts: -------------------------------------------------------------------------------- 1 | import { revalidationRoutes } from "@/lib/api/constants"; 2 | import { 3 | addStandardDelete, 4 | addStandardGetById, 5 | addStandardPatch, 6 | createHandler, 7 | } from "@/lib/api/handler"; 8 | import { 9 | deleteVenue, 10 | getVenueById, 11 | patchVenue, 12 | } from "@/lib/prisma/queries/venues"; 13 | 14 | const handler = createHandler(revalidationRoutes.venues); 15 | addStandardDelete({ 16 | handler, 17 | deleteFunc: deleteVenue, 18 | revalidationRoutes: revalidationRoutes.venues, 19 | }); 20 | 21 | addStandardGetById({ 22 | handler, 23 | getByIdFunc: getVenueById, 24 | }); 25 | 26 | addStandardPatch({ 27 | handler, 28 | patchFunc: patchVenue, 29 | revalidationRoutes: revalidationRoutes.venues, 30 | }); 31 | 32 | export default handler; 33 | -------------------------------------------------------------------------------- /src/components/lib/Style/InternalLinkKeyword.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | 4 | import { 5 | mergeClasses, 6 | styleComponentDefaultProps, 7 | StyleComponentProps, 8 | } from "."; 9 | import { Keyword } from "./Keyword"; 10 | 11 | type LinkKeywordProps = StyleComponentProps & { 12 | href: string; 13 | }; 14 | 15 | export const InternalLinkKeyword: React.FC = ({ 16 | children, 17 | className = "", 18 | href, 19 | }) => { 20 | const baseClasses = ["hover:text-aqua-500", "hover:cursor-pointer"]; 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | }; 27 | 28 | InternalLinkKeyword.defaultProps = styleComponentDefaultProps; 29 | -------------------------------------------------------------------------------- /src/components/toasts/ToastContainer.tsx: -------------------------------------------------------------------------------- 1 | // adapted from https://adamrichardson.dev/blog/custom-tailwind-toast-component 2 | import { tw } from "twind"; 3 | 4 | import Toast from "./Toast"; 5 | import { useToastStateContext } from "./ToastContext"; 6 | 7 | export function ToastContainer() { 8 | const { toasts } = useToastStateContext(); 9 | 10 | return ( 11 |
12 |
13 | {toasts && 14 | toasts.map((toast) => ( 15 | 21 | ))} 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | require("cypress-failed-log"); 23 | -------------------------------------------------------------------------------- /src/prisma/migrations/20210930201411_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[name]` on the table `Instrument` will be added. If there are existing duplicate values, this will fail. 5 | - A unique constraint covering the columns `[name]` on the table `Musician` will be added. If there are existing duplicate values, this will fail. 6 | - A unique constraint covering the columns `[name]` on the table `Venue` will be added. If there are existing duplicate values, this will fail. 7 | 8 | */ 9 | -- CreateIndex 10 | CREATE UNIQUE INDEX "Instrument_name_key" ON "Instrument"("name"); 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "Musician_name_key" ON "Musician"("name"); 14 | 15 | -- CreateIndex 16 | CREATE UNIQUE INDEX "Venue_name_key" ON "Venue"("name"); 17 | -------------------------------------------------------------------------------- /src/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | import { LoadingSpinner } from "../loading/LoadingSpinner"; 5 | import { Footer } from "./Footer"; 6 | import { Navbar } from "./Navbar"; 7 | 8 | export const Layout: React.FC = ({ children }) => ( 9 |
20 |
21 | 22 |
23 |
24 | 25 |
{children}
26 |
27 |
28 |
29 | ); 30 | -------------------------------------------------------------------------------- /src/lib/photos/components/Photos.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | import { PhotoWithShowAndVenue } from "@/lib/photos/types"; 5 | 6 | import { PhotoThumbnail } from "./PhotoThumbnail"; 7 | 8 | export const Photos: React.FC<{ 9 | photos: Array; 10 | count?: number; 11 | }> = ({ photos, count = undefined }) => { 12 | const photosSlice = count ? photos.slice(0, count) : photos; 13 | 14 | return ( 15 |
23 | {photosSlice.map((photo) => ( 24 | 25 | ))} 26 |
27 | ); 28 | }; 29 | 30 | Photos.defaultProps = { count: undefined }; 31 | -------------------------------------------------------------------------------- /src/prisma/migrations/20210930194513_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `imgPath` on the `Musician` table. All the data in the column will be lost. 5 | - You are about to drop the column `path` on the `Photo` table. All the data in the column will be lost. 6 | - Added the required column `imagePath` to the `Musician` table without a default value. This is not possible if the table is not empty. 7 | - Added the required column `imagePath` to the `Photo` table without a default value. This is not possible if the table is not empty. 8 | 9 | */ 10 | -- AlterTable 11 | ALTER TABLE "Musician" DROP COLUMN "imgPath", 12 | ADD COLUMN "imagePath" VARCHAR(255) NOT NULL; 13 | 14 | -- AlterTable 15 | ALTER TABLE "Photo" DROP COLUMN "path", 16 | ADD COLUMN "imagePath" VARCHAR(255) NOT NULL; 17 | -------------------------------------------------------------------------------- /src/lib/photos/hooks/usePhoto.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | 3 | import { fetchPhoto } from "@/lib/photos"; 4 | import { PhotoWithShowAndVenue } from "@/lib/photos/types"; 5 | import { queryKeys } from "@/lib/react-query/query-keys"; 6 | import { useHandleError } from "@/lib/react-query/useHandleError"; 7 | 8 | export const usePhoto = ({ 9 | photoId, 10 | }: { 11 | photoId?: number; 12 | }): { photo: PhotoWithShowAndVenue | undefined } => { 13 | const { handleQueryError } = useHandleError(); 14 | 15 | const { data: photo } = useQuery( 16 | [queryKeys.photos, photoId], 17 | () => { 18 | if (photoId) { 19 | return fetchPhoto(photoId); 20 | } 21 | return undefined; 22 | }, 23 | { onError: handleQueryError }, 24 | ); 25 | 26 | return { photo }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/__tests__/api/jest.setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable comma-dangle */ 2 | /* eslint-disable prettier/prettier */ 3 | /* eslint-disable import/no-extraneous-dependencies */ 4 | // Optional: configure or set up a testing framework before each test. 5 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` 6 | // Used for __tests__/testing-library.js 7 | // Learn more: https://github.com/testing-library/jest-dom 8 | import "@testing-library/jest-dom/extend-expect"; 9 | 10 | // eslint-disable-next-line no-unused-vars 11 | import { describe, expect, test } from "@jest/globals"; 12 | 13 | import { resetDB } from "./prisma/reset-db"; 14 | 15 | beforeAll(async () => { 16 | // seed db 17 | await resetDB(); 18 | }); 19 | 20 | afterEach(async () => { 21 | // reset and seed db 22 | await resetDB(); 23 | }); 24 | 25 | afterAll(async () => {}); 26 | -------------------------------------------------------------------------------- /src/lib/photos/components/DeletePhotoModal.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import React from "react"; 3 | 4 | import { DeleteItemModal } from "@/components/lib/modals/DeleteItemModal"; 5 | import { getPhotoDate } from "@/lib/photos"; 6 | import { PhotoWithShowAndVenue } from "@/lib/photos/types"; 7 | 8 | import { usePhotos } from "../hooks/usePhotos"; 9 | 10 | export const DeletePhotoModal: React.FC<{ photo: PhotoWithShowAndVenue }> = ({ 11 | photo, 12 | }) => { 13 | const { deletePhoto } = usePhotos(); 14 | const description = `Delete photo from ${dayjs(getPhotoDate(photo)).format( 15 | "MMM DD YYYY", 16 | )}?`; 17 | return ( 18 | deletePhoto(photo.id)} 22 | /> 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/lib/shows/utils.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | import { ShowWithVenue, SortedShows } from "./types"; 4 | 5 | export const formattedPerformAt = (performAt: Date): string => 6 | dayjs(performAt).format("YYYY MMM D hh:MM a"); 7 | 8 | export const sortShows = (shows: Array) => { 9 | const sortedShows: SortedShows = { 10 | upcomingShows: [], 11 | pastShows: [], 12 | }; 13 | 14 | shows.forEach((show) => { 15 | if (dayjs(show.performAt) < dayjs()) { 16 | sortedShows.pastShows.push(show); 17 | } else { 18 | sortedShows.upcomingShows.push(show); 19 | } 20 | }); 21 | 22 | // then sort within the buckets 23 | sortedShows.pastShows.sort((a, b) => (b.performAt < a.performAt ? -1 : 1)); 24 | sortedShows.upcomingShows.sort((a, b) => 25 | a.performAt < b.performAt ? -1 : 1, 26 | ); 27 | 28 | return sortedShows; 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/musicians/components/instruments/InstrumentMultiSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { MultiSelect } from "@/components/lib/form/MultiSelectInput"; 4 | 5 | import { useInstruments } from "../../hooks/useInstruments"; 6 | // import { AddInstrumentModal } from "./EditInstrumentModal"; 7 | 8 | export const InstrumentMultiSelect: React.FC = () => { 9 | const { instruments } = useInstruments(); 10 | const instrumentOptions = instruments.map((instrument) => ({ 11 | value: instrument.id, 12 | label: instrument.name, 13 | })); 14 | 15 | return ( 16 | <> 17 | 23 | {/* Avoiding dealing with "form within form" issues for now */} 24 | {/* */} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/lib/shows/components/venues/EditVenues.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | import { Heading } from "@/components/lib/Style/Heading"; 5 | import { Section } from "@/components/lib/Style/Section"; 6 | import { useVenues } from "@/lib/shows/hooks/useVenues"; 7 | import { VenueWithShowCount } from "@/lib/venues/types"; 8 | 9 | import { AddVenueModal } from "./EditVenueModal"; 10 | import { Venue } from "./Venue"; 11 | 12 | export const EditVenues: React.FC = () => { 13 | const { venues } = useVenues(); 14 | 15 | return ( 16 |
17 | Venues 18 | 19 | {venues 20 | .sort((a, b) => (a.name > b.name ? 1 : -1)) 21 | .map((venue: VenueWithShowCount) => ( 22 | 23 | ))} 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/lib/photos/dataManipulation.ts: -------------------------------------------------------------------------------- 1 | import { getPhotos } from "../prisma/queries/photos"; 2 | import { sortPhotos } from "."; 3 | 4 | export const getPhotosSortedByDate = async () => { 5 | const photos = await getPhotos(); 6 | return sortPhotos(photos); 7 | }; 8 | 9 | export const getNextAndPrevIndexes = async (id: number) => { 10 | const photos = await getPhotosSortedByDate(); 11 | let prev: number | null = null; 12 | let next: number | null = null; 13 | 14 | photos.every((photo, index) => { 15 | if (photo.id === id) { 16 | next = photos[index + 1] ? photos[index + 1].id : null; 17 | prev = photos[index - 1] ? photos[index - 1].id : null; 18 | return false; // this breaks out of the loop 19 | } 20 | return true; 21 | }); 22 | 23 | if (!prev && !next) { 24 | throw new Error(`photo id ${id} not found in photos from db`); 25 | } 26 | 27 | return { next, prev }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/lib/shows/components/venues/DeleteVenueModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { DeleteItemModal } from "@/components/lib/modals/DeleteItemModal"; 4 | import { VenueWithShowCount } from "@/lib/venues/types"; 5 | 6 | import { useVenues } from "../../hooks/useVenues"; 7 | 8 | export const DeleteVenueModal: React.FC<{ 9 | venue: VenueWithShowCount; 10 | }> = ({ venue }) => { 11 | const { deleteVenue } = useVenues(); 12 | const disabled = !!venue.showCount; 13 | const disabledMessage = `${venue.name} is associated with ${ 14 | venue.showCount 15 | } show${venue.showCount > 1 ? "s" : ""} and can't be deleted`; 16 | 17 | return ( 18 | deleteVenue(venue.id)} 23 | /> 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/toasts/useToast.ts: -------------------------------------------------------------------------------- 1 | // adapted from https://adamrichardson.dev/blog/custom-tailwind-toast-component 2 | 3 | import { useToastDispatchContext } from "./ToastContext"; 4 | import { ToastStatus } from "./types"; 5 | 6 | // eslint-disable-next-line no-unused-vars 7 | export type ToastFunction = (status: ToastStatus, message: string) => string; 8 | 9 | export function useToast(delayInMs: number = 5000) { 10 | const dispatch = useToastDispatchContext(); 11 | const showToast: ToastFunction = (status, message) => { 12 | const id = Math.random().toString(36).substring(2, 9); 13 | dispatch({ 14 | type: "ADD_TOAST", 15 | toast: { 16 | status, 17 | message, 18 | id, 19 | }, 20 | }); 21 | 22 | setTimeout(() => { 23 | dispatch({ type: "DELETE_TOAST", id }); 24 | }, delayInMs); 25 | 26 | return id; 27 | }; 28 | 29 | return { showToast }; 30 | } 31 | -------------------------------------------------------------------------------- /cypress/integration/local-only/lighthouse/lighthouse.spec.js: -------------------------------------------------------------------------------- 1 | import "@cypress-audit/lighthouse/commands"; 2 | 3 | const lighthouseThresholds = { 4 | performance: 0, 5 | accessibility: 100, 6 | "best-practices": 0, 7 | seo: 100, 8 | pwa: 0, 9 | }; 10 | 11 | it("should pass the audits", () => { 12 | // TODO: these are VERY slow (3 minutes). 13 | // Can they be sped up? 14 | cy.task("db:reset").visit("/"); 15 | cy.log("checking /"); 16 | cy.lighthouse(lighthouseThresholds); 17 | 18 | cy.visit("/shows"); 19 | cy.log("checking /shows"); 20 | cy.lighthouse(lighthouseThresholds); 21 | 22 | cy.visit("/photos"); 23 | cy.log("checking /photos"); 24 | cy.lighthouse(lighthouseThresholds); 25 | 26 | cy.visit("/band"); 27 | cy.log("checking /band"); 28 | cy.lighthouse(lighthouseThresholds); 29 | 30 | cy.visit("/more"); 31 | cy.log("checking /more"); 32 | cy.lighthouse(lighthouseThresholds); 33 | }); 34 | -------------------------------------------------------------------------------- /src/__tests__/ui/tests/press.test.tsx: -------------------------------------------------------------------------------- 1 | import { axe, toHaveNoViolations } from "jest-axe"; 2 | 3 | import Press from "@/pages/press"; 4 | import { render, screen, waitFor } from "@/test-utils"; 5 | 6 | expect.extend(toHaveNoViolations); 7 | 8 | test("should have no a11y errors caught by jest-axe", async () => { 9 | const { container } = render(); 10 | 11 | await waitFor(() => { 12 | const imgs = screen.getAllByRole("img"); 13 | expect(imgs).toHaveLength(2); 14 | }); 15 | 16 | const results = await axe(container); 17 | expect(results).toHaveNoViolations(); 18 | }); 19 | 20 | test("should contain expected elements", async () => { 21 | render(); 22 | 23 | const heading = screen.getByText("Press"); 24 | expect(heading).toBeInTheDocument(); 25 | 26 | await waitFor(() => { 27 | const imgs = screen.getAllByAltText(/ten-cent teacakes/i); 28 | expect(imgs).toHaveLength(2); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/pages/api/auth/whitelist.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { processApiError } from "@/lib/api/utils"; 4 | import { WhitelistResponse } from "@/lib/auth/types"; 5 | 6 | export default async function handle( 7 | req: NextApiRequest, 8 | res: NextApiResponse, 9 | ) { 10 | const { method } = req; 11 | try { 12 | const whitelistString = process.env.AUTH0_WHITELIST; 13 | const whitelist = whitelistString ? whitelistString.split("|") : []; 14 | const response: WhitelistResponse = { whitelist }; 15 | switch (method) { 16 | case "GET": 17 | return res.json(response); 18 | default: 19 | res.setHeader("Allow", ["GET"]); 20 | return res.status(405).end(`Method ${method} Not Allowed`); 21 | } 22 | } catch (error) { 23 | const { status, message } = processApiError(error); 24 | return res.status(status).json({ message }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/__tests__/api/tests/revalidationAuth.test.ts: -------------------------------------------------------------------------------- 1 | import { testApiHandler } from "next-test-api-route-handler"; 2 | 3 | import photosHandler from "@/pages/api/photos/index"; 4 | 5 | test("PUT /api/photos returns 401 status for incorrect revalidation secret", async () => { 6 | await testApiHandler({ 7 | handler: photosHandler, 8 | paramsPatcher: (params) => { 9 | params.queryStringURLParams = { secret: "NOT THE REAL SECRET" }; 10 | }, 11 | test: async ({ fetch }) => { 12 | const res = await fetch({ method: "PUT" }); 13 | expect(res.status).toEqual(401); 14 | }, 15 | }); 16 | }); 17 | 18 | test("GET /api/photos DOES NOT return 401 status for missing revalidation secret", async () => { 19 | await testApiHandler({ 20 | handler: photosHandler, 21 | test: async ({ fetch }) => { 22 | const res = await fetch({ method: "GET" }); 23 | expect(res.status).toEqual(200); 24 | }, 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/prisma/migrations/20220131195244_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `imagePixelHeight` to the `Musician` table without a default value. This is not possible if the table is not empty. 5 | - Added the required column `imagePixelWidth` to the `Musician` table without a default value. This is not possible if the table is not empty. 6 | - Added the required column `pixelHeight` to the `Photo` table without a default value. This is not possible if the table is not empty. 7 | - Added the required column `pixelWidth` to the `Photo` table without a default value. This is not possible if the table is not empty. 8 | 9 | */ 10 | -- AlterTable 11 | ALTER TABLE "Musician" ADD COLUMN "imagePixelHeight" INTEGER NOT NULL, 12 | ADD COLUMN "imagePixelWidth" INTEGER NOT NULL; 13 | 14 | -- AlterTable 15 | ALTER TABLE "Photo" ADD COLUMN "pixelHeight" INTEGER NOT NULL, 16 | ADD COLUMN "pixelWidth" INTEGER NOT NULL; 17 | -------------------------------------------------------------------------------- /src/components/loading/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { useUser } from "@auth0/nextjs-auth0"; 2 | import React from "react"; 3 | import { CgSpinner } from "react-icons/cg"; 4 | import { useIsFetching, useIsMutating } from "react-query"; 5 | import { tw } from "twind"; 6 | 7 | export const LoadingSpinner: React.FC = () => { 8 | const isMutating = useIsMutating(); 9 | const isFetching = useIsFetching(); 10 | const { isLoading } = useUser(); 11 | 12 | return ( 13 |
24 |
25 | 30 |
31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/__mocks__/msw/handlers/get.ts: -------------------------------------------------------------------------------- 1 | import { rest } from "msw"; 2 | 3 | import { 4 | mockInstruments, 5 | mockMusicians, 6 | mockPhotos, 7 | mockShows, 8 | mockVenues, 9 | mockWhitelist, 10 | } from "@/__mocks__/mockData"; 11 | 12 | export const getHandlers = [ 13 | rest.get("http://localhost:3000/api/shows", (req, res, ctx) => 14 | res(ctx.json(mockShows)), 15 | ), 16 | rest.get("http://localhost:3000/api/venues", (req, res, ctx) => 17 | res(ctx.json(mockVenues)), 18 | ), 19 | rest.get("http://localhost:3000/api/photos", (req, res, ctx) => 20 | res(ctx.json(mockPhotos)), 21 | ), 22 | rest.get("http://localhost:3000/api/instruments", (req, res, ctx) => 23 | res(ctx.json(mockInstruments)), 24 | ), 25 | rest.get("http://localhost:3000/api/musicians", (req, res, ctx) => 26 | res(ctx.json(mockMusicians)), 27 | ), 28 | rest.get("http://localhost:3000/api/auth/whitelist", (req, res, ctx) => 29 | res(ctx.json({ whitelist: mockWhitelist })), 30 | ), 31 | ]; 32 | -------------------------------------------------------------------------------- /src/pages/api/revalidate/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { createHandler } from "@/lib/api/handler"; 4 | 5 | const revalidationRoutes = ["/", "/photos", "/band"]; 6 | 7 | const handler = createHandler(); 8 | handler.get(async (req: NextApiRequest, res: NextApiResponse) => { 9 | if (process.env.APP_ENV !== "test") { 10 | return res 11 | .status(401) 12 | .json({ message: "endpoint only available for test use" }); 13 | } 14 | 15 | if (req.query.secret !== process.env.REVALIDATION_SECRET) { 16 | return res.status(401).json({ message: "invalid revalidation secret" }); 17 | } 18 | 19 | // revalidate pages that can have ISR data updates 20 | // note: this will change to `res.revalidate` when 21 | // revalidate-on-demand is out of beta 22 | await Promise.all( 23 | revalidationRoutes.map((route) => res.unstable_revalidate(route)), 24 | ); 25 | 26 | return res.status(200).end(); 27 | }); 28 | 29 | export default handler; 30 | -------------------------------------------------------------------------------- /src/components/lib/Style/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | export type StyleComponentProps = { 5 | children: React.ReactNode; 6 | className?: string | Array; 7 | }; 8 | 9 | export type StyleComponentType = React.FC; 10 | 11 | export const styleComponentDefaultProps = { className: "" }; 12 | 13 | export const mergeClasses = ( 14 | baseClasses: Array, 15 | extraClasses: string | Array, 16 | ): Array => { 17 | const classes = baseClasses; 18 | if (typeof extraClasses === "string") { 19 | classes.push(extraClasses); 20 | } else { 21 | classes.push(...extraClasses); 22 | } 23 | return classes; 24 | }; 25 | 26 | export const StyleComponent: React.FC<{ 27 | children: React.ReactNode; 28 | baseClasses: Array; 29 | extraClasses: string | Array; 30 | }> = ({ children, baseClasses, extraClasses }) => ( 31 | 32 | {children} 33 | 34 | ); 35 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable comma-dangle */ 2 | /* eslint-disable prettier/prettier */ 3 | /* eslint-disable import/no-extraneous-dependencies */ 4 | // Optional: configure or set up a testing framework before each test. 5 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` 6 | // Used for __tests__/testing-library.js 7 | // Learn more: https://github.com/testing-library/jest-dom 8 | import "@testing-library/jest-dom/extend-expect"; 9 | 10 | // eslint-disable-next-line no-unused-vars 11 | import { describe, expect, test } from "@jest/globals"; 12 | 13 | import { server } from "./src/__mocks__/msw/server"; 14 | 15 | beforeAll(() => { 16 | // msw: Establish API mocking before all tests. 17 | server.listen(); 18 | }); 19 | 20 | afterEach(() => { 21 | // msw: Reset any request handlers that we may add during the tests, 22 | // so they don't affect other tests. 23 | server.resetHandlers(); 24 | }); 25 | 26 | afterAll(() => { 27 | // msw: Clean up after the tests are finished. 28 | server.close(); 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/lib/form/FieldContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorMessage } from "formik"; 2 | import React from "react"; 3 | import { tw } from "twind"; 4 | 5 | export const FieldContainer: React.FC<{ 6 | htmlFor: string; 7 | label: string; 8 | required: boolean; 9 | fieldName: string; 10 | }> = ({ htmlFor, label, required, children, fieldName }) => ( 11 |
12 |
13 |
14 | 21 | {children} 22 | 23 | 24 | 25 |
26 |
27 |
28 | ); 29 | -------------------------------------------------------------------------------- /src/lib/supabase/hooks/useSupabasePhoto.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/nextjs"; 2 | import { useState } from "react"; 3 | 4 | import { useAsync } from "@/lib/hooks/useAsync"; 5 | 6 | import { supabase } from ".."; 7 | 8 | const FIVE_MINUTES = 60 * 5; 9 | 10 | export const getSignedStorageUrl = async ( 11 | path: string | null, 12 | bucketPath: string, 13 | ): Promise => { 14 | if (!path) return null; 15 | 16 | const { signedURL, error } = await supabase.storage 17 | .from(bucketPath) 18 | .createSignedUrl(path, FIVE_MINUTES); 19 | 20 | if (error) { 21 | Sentry.captureException(error); 22 | throw error; 23 | } 24 | 25 | return signedURL; 26 | }; 27 | 28 | export const useSupabasePhoto = ( 29 | path: string | null, 30 | bucketPath: string, 31 | ): { imgSrc: string | null } => { 32 | const [imgSrc, setImgSrc] = useState(null); 33 | useAsync( 34 | () => getSignedStorageUrl(path, bucketPath), 35 | (signedPath) => setImgSrc(signedPath), 36 | ); 37 | return { imgSrc }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/lib/musicians/components/instruments/DeleteInstrumentModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { DeleteItemModal } from "@/components/lib/modals/DeleteItemModal"; 4 | import { InstrumentWithMusicianCount } from "@/lib/instruments/types"; 5 | 6 | import { useInstruments } from "../../hooks/useInstruments"; 7 | 8 | export const DeleteInstrumentModal: React.FC<{ 9 | instrument: InstrumentWithMusicianCount; 10 | }> = ({ instrument }) => { 11 | const { deleteInstrument } = useInstruments(); 12 | const disabledMessage = `${instrument.name} is associated with ${ 13 | instrument.musicianCount 14 | } musician${instrument.musicianCount > 1 ? "s" : ""} and can't be deleted`; 15 | const disabled = instrument.musicianCount > 0; 16 | return ( 17 | deleteInstrument(instrument.id)} 24 | /> 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | /* eslint-disable vars-on-top */ 3 | 4 | // reference: https://github.com/prisma/prisma-examples/blob/latest/typescript/rest-nextjs-api-routes/lib/prisma.ts 5 | 6 | import { PrismaClient } from "@prisma/client"; 7 | 8 | declare global { 9 | // eslint-disable-next-line no-var 10 | var prisma: PrismaClient | undefined; 11 | } 12 | 13 | // PrismaClient is attached to the `global` object in development to prevent 14 | // exhausting your database connection limit. 15 | // 16 | // Learn more: 17 | // https://pris.ly/d/help/next-js-best-practices 18 | 19 | // eslint-disable-next-line import/no-mutable-exports 20 | let prismaClient: PrismaClient; 21 | const prismaClientOptions = { 22 | rejectOnNotFound: true, 23 | }; 24 | 25 | if (process.env.NODE_ENV === "production") { 26 | prismaClient = new PrismaClient(prismaClientOptions); 27 | } else { 28 | if (!global.prisma) { 29 | global.prisma = new PrismaClient(prismaClientOptions); 30 | } 31 | prismaClient = global.prisma; 32 | } 33 | 34 | export default prismaClient; 35 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // https://nextjs.org/docs/testing#setting-up-jest-with-the-rust-compiler 2 | 3 | const nextJest = require("next/jest"); 4 | // eslint-disable-next-line import/no-extraneous-dependencies 5 | const { defaults } = require("jest-config"); 6 | 7 | const createJestConfig = nextJest({ 8 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 9 | dir: "./", 10 | }); 11 | 12 | // Add any custom config to be passed to Jest 13 | const customJestConfig = { 14 | moduleNameMapper: { 15 | // Handle module aliases 16 | "^@/(.*)$": "/src/$1", 17 | }, 18 | setupFilesAfterEnv: ["/jest.setup.js"], 19 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 20 | moduleDirectories: ["node_modules", "/"], 21 | testEnvironment: "jest-environment-jsdom", 22 | moduleFileExtensions: [...defaults.moduleFileExtensions, "ts", "tsx"], 23 | }; 24 | 25 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 26 | module.exports = createJestConfig(customJestConfig); 27 | -------------------------------------------------------------------------------- /src/components/Layout/Navbar/SocialLinks.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IconType } from "react-icons"; 3 | import { AiFillTwitterCircle, AiFillYoutube } from "react-icons/ai"; 4 | import { tw } from "twind"; 5 | 6 | type SocialLinkProps = { 7 | Icon: IconType; 8 | target: string; 9 | label: string; 10 | }; 11 | 12 | const SocialLink: React.FC = ({ Icon, target, label }) => ( 13 |
14 | 23 | 24 | 25 |
26 | ); 27 | 28 | export const SocialLinks = () => ( 29 |
30 | 35 | 40 |
41 | ); 42 | -------------------------------------------------------------------------------- /src/lib/musicians/components/instruments/EditInstruments.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | import { Heading } from "@/components/lib/Style/Heading"; 5 | import { Section } from "@/components/lib/Style/Section"; 6 | import { InstrumentWithMusicianCount } from "@/lib/instruments/types"; 7 | 8 | import { DeleteInstrumentModal } from "./DeleteInstrumentModal"; 9 | import { AddInstrumentModal, EditInstrumentModal } from "./EditInstrumentModal"; 10 | 11 | export const EditInstruments: React.FC<{ 12 | instruments: Array; 13 | }> = ({ instruments }) => ( 14 |
15 | Instruments 16 | 17 | {instruments 18 | .sort((a, b) => (a.name > b.name ? 1 : -1)) 19 | .map((instrument: InstrumentWithMusicianCount) => ( 20 |
21 | 22 | 23 | {instrument.name} 24 |
25 | ))} 26 |
27 | ); 28 | -------------------------------------------------------------------------------- /src/lib/photos/types.ts: -------------------------------------------------------------------------------- 1 | import { Photo, Show, Venue } from "@prisma/client"; 2 | 3 | export type PhotoFormData = { 4 | showId?: number; 5 | photoFile?: File; 6 | imagePath?: string; 7 | photographer?: string; 8 | description?: string; 9 | takenAt?: Date; 10 | }; 11 | 12 | export type UploadedPhotoFormData = { 13 | showId?: number; 14 | imagePath?: string; 15 | photographer?: string; 16 | description?: string; 17 | takenAt?: Date; 18 | }; 19 | 20 | export type PhotoPutData = { 21 | showId?: number; 22 | imagePath: string; 23 | photographer?: string; 24 | description?: string; 25 | takenAt?: string; 26 | }; 27 | 28 | export type PhotoPatchData = { 29 | showId?: number; 30 | photographer?: string; 31 | description?: string; 32 | takenAt?: string; 33 | }; 34 | 35 | export type PhotoPatchArgs = { 36 | id: number; 37 | data: PhotoPatchData; 38 | }; 39 | 40 | export type PhotoWithShowAndVenue = Photo & { 41 | show?: Show | null; 42 | showVenue?: Venue | null; 43 | takenAt?: Date | null; 44 | }; 45 | 46 | export type NextAndPrev = { 47 | next: number | null; 48 | prev: number | null; 49 | }; 50 | export type NextAndPrevObject = Record; 51 | -------------------------------------------------------------------------------- /src/lib/react-query/useHandleError.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useToastDispatchContext } from "@/components/toasts/ToastContext"; 4 | import { useToast } from "@/components/toasts/useToast"; 5 | 6 | const getErrorMessage = (error: unknown): string => 7 | error instanceof Error 8 | ? // remove the initial 'Error: ' that accompanies many errors 9 | error.message 10 | : "error connecting to server"; 11 | 12 | export const useHandleError = () => { 13 | const { showToast } = useToast(); 14 | const [currentErrorToastId, setCurrentErrorToastId] = 15 | React.useState(""); 16 | const dispatch = useToastDispatchContext(); 17 | 18 | const handleQueryError = (error: unknown) => { 19 | const message = getErrorMessage(error); 20 | // remove previous error toast to prevent duplicates 21 | if (currentErrorToastId) 22 | dispatch({ type: "DELETE_TOAST", id: currentErrorToastId }); 23 | const id = showToast("error", message); 24 | setCurrentErrorToastId(id); 25 | }; 26 | 27 | const handleMutateError = (error: unknown, action: string) => { 28 | showToast("error", getErrorMessage(error)); 29 | }; 30 | 31 | return { handleQueryError, handleMutateError }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/lib/shows/components/EditableShow.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import dayjs from "dayjs"; 3 | import { useField } from "formik"; 4 | import React from "react"; 5 | import { tw } from "twind"; 6 | 7 | import { FieldContainer } from "@/components/lib/form/FieldContainer"; 8 | import { useShows } from "@/lib/shows/hooks/useShows"; 9 | 10 | export const EditableShow: React.FC<{ required: boolean }> = ({ required }) => { 11 | const { pastShows } = useShows(); 12 | 13 | const [field] = useField({ 14 | name: "showId", 15 | type: "select", 16 | }); 17 | 18 | return ( 19 | 25 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorTheme": "Night Owl (No Italics)", 3 | "eslint.options": { 4 | "configFile": "./.eslintrc.json" 5 | }, 6 | "eslint.nodePath": "./node_modules", 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact", 10 | "typescript", 11 | "typescriptreact" 12 | ], 13 | "[json, javascript, javascriptreact, typescript, typescriptreact]": { 14 | "editor.tabSize": 2 15 | }, 16 | "editor.defaultFormatter": "esbenp.prettier-vscode", 17 | "editor.formatOnSave": true, 18 | "editor.renderWhitespace": "boundary", 19 | "editor.codeActionsOnSave": { 20 | "source.fixAll.eslint": true 21 | }, 22 | "window.zoomLevel": 2, 23 | "workbench.activityBar.visible": true, 24 | "editor.minimap.enabled": false, 25 | "typescript.updateImportsOnFileMove.enabled": "always", 26 | "workbench.editorAssociations": { 27 | "*.ipynb": "jupyter-notebook" 28 | }, 29 | "javascript.updateImportsOnFileMove.enabled": "always", 30 | "eslint.alwaysShowStatus": true, 31 | "security.workspace.trust.untrustedFiles": "open", 32 | "typescript.tsdk": "node_modules/typescript/lib", 33 | "prettier.trailingComma": "all", 34 | "files.associations": { 35 | ".env*": "env" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/shows/components/Show.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | import { useWhitelistUser } from "@/lib/auth/useWhitelistUser"; 5 | import { ShowWithVenue } from "@/lib/shows/types"; 6 | 7 | import { DeleteShowModal } from "./DeleteShowModal"; 8 | import { EditShowModal } from "./EditShowModal"; 9 | import { ShowDate } from "./ShowDate"; 10 | import { DisplayShowVenue } from "./ShowVenue"; 11 | 12 | export const Show: React.FC<{ show: ShowWithVenue }> = ({ show }) => { 13 | const { user } = useWhitelistUser(); 14 | 15 | const showClasses = tw(["flex", "sm:flex-row", "flex-col", "ml-5", "my-5"]); 16 | 17 | return ( 18 |
19 |
20 | 21 |
22 |
23 | {user ? ( 24 | 25 | 26 | 27 | 28 | ) : null} 29 | 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | DocumentContext, 3 | Head, 4 | Html, 5 | Main, 6 | NextScript, 7 | } from "next/document"; 8 | import { tw } from "twind"; 9 | 10 | class MyDocument extends Document { 11 | static async getInitialProps(ctx: DocumentContext) { 12 | const initialProps = await Document.getInitialProps(ctx); 13 | return { ...initialProps }; 14 | } 15 | 16 | render() { 17 | return ( 18 | 19 | 20 | 21 | 22 | 27 | 31 | 35 | 36 | 37 |
38 | 39 | 40 | 41 | ); 42 | } 43 | } 44 | 45 | export default MyDocument; 46 | -------------------------------------------------------------------------------- /.env.test.local_template: -------------------------------------------------------------------------------- 1 | CYPRESS_baseUrl="http://localhost:3000" 2 | 3 | # for cypress / auth0 4 | CYPRESS_LOCALSTORAGE_KEY="" 5 | 6 | # to get these values, go to 7 | # https://auth0.com/docs/quickstart/webapp/nextjs/01-login#configure-the-sdk 8 | # and click "log in" 9 | 10 | # A long secret value used to encrypt the session cookie 11 | AUTH0_SECRET="" 12 | 13 | # The base url of your application 14 | AUTH0_BASE_URL="" 15 | 16 | # The url of your Auth0 tenant domain 17 | AUTH0_ISSUER_BASE_URL="" 18 | 19 | # Your Auth0 application's Client ID 20 | AUTH0_CLIENT_ID="" 21 | 22 | # Your Auth0 application's Client Secret 23 | AUTH0_CLIENT_SECRET="" 24 | 25 | # should match the one in tenant -> settings -> API Authorization Settings 26 | AUTH0_AUDIENCE="" 27 | 28 | # value should probably be "openid profile email" 29 | AUTH0_SCOPE="openid profile email" 30 | 31 | # for supabase cleanout 32 | # Go to project > settings > api 33 | SUPABASE_URL="" 34 | 35 | # public anonymous key (also project > settings > api) 36 | SUPABASE_KEY="" 37 | 38 | # can use local or cloud db 39 | DATABASE_URL="" 40 | 41 | # see https://github.com/vercel/next.js/issues/26983 42 | NODE_ENV="test" 43 | 44 | # try `openssl rand -base64 64` for a random secret 45 | REVALIDATION_SECRET="" 46 | 47 | # for revalidation endpoint 48 | APP_ENV="test" -------------------------------------------------------------------------------- /src/components/lib/Popover.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { tw } from "twind"; 3 | 4 | export const Popover: React.FC<{ 5 | message: string; 6 | }> = ({ message, children }) => { 7 | const [popoverHidden, setPopoverHidden] = useState(true); 8 | const showPopover = () => setPopoverHidden(false); 9 | const hidePopover = () => setPopoverHidden(true); 10 | 11 | const classes = tw([ 12 | popoverHidden ? "hidden" : "", 13 | "px-6", 14 | "py-2.5", 15 | "bg-white", 16 | "text-aqua-800", 17 | "font-medium", 18 | "leading-tight", 19 | "rounded", 20 | "shadow-lg", 21 | "focus:outline-none", 22 | "focus:ring-0", 23 | "transition", 24 | "duration-150", 25 | "ease-in-out", 26 | "overflow-x-hidden", 27 | "overflow-y-auto", 28 | "absolute", 29 | "z-50", 30 | "outline-none", 31 | "focus:outline-none", 32 | "text-lg", 33 | ]); 34 | 35 | return ( 36 | <> 37 | 43 | {children} 44 | 45 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/pages/auth/login.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-html-link-for-pages */ 2 | // adapted from https://auth0.com/blog/ultimate-guide-nextjs-authentication-auth0/ 3 | 4 | import { useUser } from "@auth0/nextjs-auth0"; 5 | import { tw } from "twind"; 6 | 7 | import { Button } from "@/components/lib/Button"; 8 | import { Heading } from "@/components/lib/Style/Heading"; 9 | import { useToast } from "@/components/toasts/useToast"; 10 | 11 | const Login = () => { 12 | const { user, error, isLoading } = useUser(); 13 | const { showToast } = useToast(); 14 | 15 | if (isLoading) return
Loading...
; 16 | if (error) showToast("error", `Login error: ${error.message}`); 17 | 18 | if (user) { 19 | return ( 20 |
21 | Welcome! 22 |

You are logged in as {user.name}

23 | 24 | 25 | 26 |
27 | ); 28 | } 29 | 30 | return ( 31 |
32 | Log in 33 |

Note: this button may take you to an Auth0 login screen.

34 | 35 | 36 | 37 |
38 | ); 39 | }; 40 | 41 | export default Login; 42 | -------------------------------------------------------------------------------- /src/prisma/migrations/20211014183802_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `eventId` on the `Photo` table. All the data in the column will be lost. 5 | - You are about to drop the `Event` table. If the table is not empty, all the data it contains will be lost. 6 | - Added the required column `showId` to the `Photo` table without a default value. This is not possible if the table is not empty. 7 | 8 | */ 9 | -- DropForeignKey 10 | ALTER TABLE "Event" DROP CONSTRAINT "Event_venueId_fkey"; 11 | 12 | -- DropForeignKey 13 | ALTER TABLE "Photo" DROP CONSTRAINT "Photo_eventId_fkey"; 14 | 15 | -- AlterTable 16 | ALTER TABLE "Photo" DROP COLUMN "eventId", 17 | ADD COLUMN "showId" INTEGER NOT NULL; 18 | 19 | -- DropTable 20 | DROP TABLE "Event"; 21 | 22 | -- CreateTable 23 | CREATE TABLE "Show" ( 24 | "id" SERIAL NOT NULL, 25 | "performAt" TIMESTAMP(3) NOT NULL, 26 | "venueId" INTEGER NOT NULL, 27 | 28 | CONSTRAINT "Show_pkey" PRIMARY KEY ("id") 29 | ); 30 | 31 | -- AddForeignKey 32 | ALTER TABLE "Show" ADD CONSTRAINT "Show_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 33 | 34 | -- AddForeignKey 35 | ALTER TABLE "Photo" ADD CONSTRAINT "Photo_showId_fkey" FOREIGN KEY ("showId") REFERENCES "Show"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 36 | -------------------------------------------------------------------------------- /src/lib/shows/components/venues/Venue.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { tw } from "twind"; 3 | 4 | import { VenueWithShowCount } from "@/lib/venues/types"; 5 | 6 | import { DeleteVenueModal } from "./DeleteVenueModal"; 7 | import { EditVenueModal } from "./EditVenueModal"; 8 | 9 | export const Venue: React.FC<{ venue: VenueWithShowCount }> = ({ venue }) => { 10 | const venueLink = 11 | venue.url && !venue.url?.match(/^https?:\/\//) 12 | ? `http://${venue.url}` 13 | : venue.url; 14 | 15 | const venueClasses = tw([ 16 | "flex", 17 | "sm:flex-row", 18 | "ml-5", 19 | "my-5", 20 | "items-center", 21 | ]); 22 | 23 | return ( 24 |
25 |
38 | {venue.name} 39 |
40 |
41 | 42 | 43 |
44 | {venueLink ? {venue.url} : venue.url} 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/pages/photos/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import React from "react"; 3 | import { tw } from "twind"; 4 | 5 | import { Heading } from "@/components/lib/Style/Heading"; 6 | import { useWhitelistUser } from "@/lib/auth/useWhitelistUser"; 7 | import { AddPhotoModal } from "@/lib/photos/components/EditPhotoModal"; 8 | import { Photos } from "@/lib/photos/components/Photos"; 9 | import { getPhotosSortedByDate } from "@/lib/photos/dataManipulation"; 10 | import { PhotoWithShowAndVenue } from "@/lib/photos/types"; 11 | 12 | export async function getStaticProps() { 13 | const sortedPhotos = await getPhotosSortedByDate(); 14 | 15 | return { 16 | // dates in photos are not serializable 17 | props: { 18 | photosJSON: JSON.stringify(sortedPhotos), 19 | }, 20 | }; 21 | } 22 | 23 | const PhotosPage: React.FC<{ photosJSON: string }> = ({ photosJSON }) => { 24 | const photos: Array = JSON.parse(photosJSON); 25 | const { user } = useWhitelistUser(); 26 | 27 | return ( 28 | <> 29 | 30 | Ten-Cent Teacakes: Photos 31 | 32 | Photos 33 | {user ? ( 34 |
35 | 36 |
37 | ) : null} 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default PhotosPage; 44 | -------------------------------------------------------------------------------- /src/lib/prisma/queries/venues.ts: -------------------------------------------------------------------------------- 1 | import prisma from "@/lib/prisma"; 2 | import { 3 | VenuePatchData, 4 | VenuePutData, 5 | VenueWithShowCount, 6 | } from "@/lib/venues/types"; 7 | 8 | export const getVenues = async (): Promise> => { 9 | const venues = await prisma.venue.findMany({ 10 | orderBy: { name: "asc" }, 11 | include: { 12 | _count: { 13 | select: { shows: true }, 14 | }, 15 | }, 16 | }); 17 | return venues.map((v) => ({ 18 | id: v.id, 19 | name: v.name, 20 | url: v.url, 21 | // eslint-disable-next-line no-underscore-dangle 22 | showCount: v._count?.shows ?? 0, 23 | })); 24 | }; 25 | 26 | export const getVenueById = async (id: number) => 27 | prisma.venue.findUnique({ where: { id } }); 28 | 29 | export const addVenue = async (data: VenuePutData) => { 30 | await prisma.venue.create({ 31 | data: { name: data.name, url: data.url ?? undefined }, 32 | }); 33 | return prisma.venue.findUnique({ where: { name: data.name } }); 34 | }; 35 | 36 | export const patchVenue = async ({ data, id }: VenuePatchData) => { 37 | const venueData = await getVenueById(id); 38 | if (!venueData) { 39 | throw new Error(`Bad venue id: ${id}`); 40 | } 41 | 42 | return prisma.venue.update({ data, where: { id } }); 43 | }; 44 | 45 | export const deleteVenue = async (id: number) => { 46 | await prisma.venue.delete({ where: { id } }); 47 | }; 48 | -------------------------------------------------------------------------------- /src/lib/venues/index.ts: -------------------------------------------------------------------------------- 1 | import { Venue } from ".prisma/client"; 2 | 3 | import { AxiosResponse } from "axios"; 4 | 5 | import { 6 | axiosInstance, 7 | revalidationAxiosInstance, 8 | } from "../axios/axiosInstance"; 9 | import { routes } from "../axios/constants"; 10 | import { 11 | VenuePatchArgs, 12 | VenuePatchResponse, 13 | VenuePutData, 14 | VenueResponse, 15 | VenueWithShowCount, 16 | } from "./types"; 17 | 18 | /* * methods * */ 19 | export const fetchVenues = async (): Promise> => { 20 | const { data } = await axiosInstance.get(`/api/${routes.venues}`); 21 | return data; 22 | }; 23 | 24 | export const addVenue = async (data: VenuePutData): Promise => { 25 | const { data: venue } = await revalidationAxiosInstance.put< 26 | { data: VenuePutData }, 27 | AxiosResponse 28 | >(`/api/${routes.venues}`, { 29 | data, 30 | }); 31 | return { venue }; 32 | }; 33 | 34 | export const patchVenue = async ({ 35 | id, 36 | data, 37 | }: VenuePatchArgs): Promise => { 38 | const { data: venue } = await revalidationAxiosInstance.patch< 39 | { data: VenuePutData }, 40 | AxiosResponse 41 | >(`/api/${routes.venues}/${id}`, { 42 | data, 43 | }); 44 | return { venue }; 45 | }; 46 | 47 | export const deleteVenue = async (id: number): Promise => 48 | revalidationAxiosInstance.delete(`/api/${routes.venues}/${id}`); 49 | -------------------------------------------------------------------------------- /src/components/lib/form/TextArea.tsx: -------------------------------------------------------------------------------- 1 | // adapted from https://tailwindui.com/components/application-ui/forms/form-layouts 2 | 3 | import { useField } from "formik"; 4 | import React from "react"; 5 | import { tw } from "twind"; 6 | 7 | import { FieldContainer } from "./FieldContainer"; 8 | 9 | type TextAreaProps = { 10 | name: string; 11 | label: string; 12 | placeholderText?: string; 13 | required: boolean; 14 | }; 15 | 16 | export const TextArea: React.FC = ({ 17 | name, 18 | label, 19 | placeholderText = undefined, 20 | required, 21 | }) => { 22 | const [field] = useField({ name }); 23 | return ( 24 | 30 |
31 |