diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..a680367 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1 @@ +.next diff --git a/frontend/components/Button.tsx b/frontend/components/Button.tsx new file mode 100644 index 0000000..d61c84c --- /dev/null +++ b/frontend/components/Button.tsx @@ -0,0 +1,65 @@ +import { MouseEventHandler, ReactNode } from "react"; + +export enum ButtonStyle { + primary, + success, + danger, +} + +export interface Props { + onClick?: MouseEventHandler; + style?: ButtonStyle; + bold?: boolean; + children?: ReactNode; +} + +export default function Button(props: Props) { + if (props.style === undefined) { + return PrimaryButton(props); + } + + switch (props.style) { + case ButtonStyle.primary: + return PrimaryButton(props); + case ButtonStyle.success: + return SuccessButton(props); + case ButtonStyle.danger: + return DangerButton(props); + } +} + +function PrimaryButton(props: Props) { + return ( + + ); +} + +function SuccessButton(props: Props) { + return ( + + ); +} + +function DangerButton(props: Props) { + return ( + + ); +} diff --git a/frontend/components/Editable.tsx b/frontend/components/Editable.tsx index a3bdef6..c796b12 100644 --- a/frontend/components/Editable.tsx +++ b/frontend/components/Editable.tsx @@ -1,128 +1,129 @@ import { - EmojiLaughing, - HandThumbsDown, - HandThumbsUp, - Heart, - People, - Trash3, + EmojiLaughing, + HandThumbsDown, + HandThumbsUp, + Heart, + People, + Trash3, } from "react-bootstrap-icons"; import Card from "./Card"; import TextInput from "./TextInput"; +import Button, { ButtonStyle } from "./Button"; export interface EditField { - id: number; - name: string; - pronouns: Record; + id: number; + name: string; + pronouns: Record; } export enum PronounChoice { - favourite, - okay, - jokingly, - friendsOnly, - avoid, + favourite, + okay, + jokingly, + friendsOnly, + avoid, } type EditableCardProps = { - field: EditField; - onChangeName: React.ChangeEventHandler; - onChangeFavourite( - e: React.MouseEvent, - entry: string - ): void; - onChangeOkay(e: React.MouseEvent, entry: string): void; - onChangeJokingly(e: React.MouseEvent, entry: string): void; - onChangeFriends(e: React.MouseEvent, entry: string): void; - onChangeAvoid(e: React.MouseEvent, entry: string): void; - onClickDelete: React.MouseEventHandler; + field: EditField; + onChangeName: React.ChangeEventHandler; + onChangeFavourite( + e: React.MouseEvent, + entry: string + ): void; + onChangeOkay(e: React.MouseEvent, entry: string): void; + onChangeJokingly(e: React.MouseEvent, entry: string): void; + onChangeFriends(e: React.MouseEvent, entry: string): void; + onChangeAvoid(e: React.MouseEvent, entry: string): void; + onClickDelete: React.MouseEventHandler; }; export function EditableCard(props: EditableCardProps) { - const footer = ( -
- - -
- ); + const footer = ( +
+ + +
+ ); - return ( - -
    - {Object.keys(props.field.pronouns).map((pronoun, index) => { - const choice = props.field.pronouns[pronoun]; - return ( -
  • -
    {pronoun}
    -
    - - - - - - -
    -
  • - ); - })} -
-
- ); + return ( + +
    + {Object.keys(props.field.pronouns).map((pronoun, index) => { + const choice = props.field.pronouns[pronoun]; + return ( +
  • +
    {pronoun}
    +
    + + + + + + +
    +
  • + ); + })} +
+
+ ); } diff --git a/frontend/components/TextInput.tsx b/frontend/components/TextInput.tsx index 2d7e5ff..a2b8ba8 100644 --- a/frontend/components/TextInput.tsx +++ b/frontend/components/TextInput.tsx @@ -1,19 +1,24 @@ import { ChangeEventHandler } from "react"; export type Props = { - defaultValue?: string; - value?: string; - onChange?: ChangeEventHandler; + contrastBackground?: boolean; + defaultValue?: string; + value?: string; + onChange?: ChangeEventHandler; }; export default function TextInput(props: Props) { - return ( - - ); + const bg = props.contrastBackground + ? "bg-slate-50 dark:bg-slate-700" + : "bg-white dark:bg-slate-800"; + + return ( + + ); } diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 99b94a8..26bb632 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -69,13 +69,28 @@ export enum WordStatus { export enum ErrorCode { BadRequest = 400, Forbidden = 403, + NotFound = 404, + MethodNotAllowed = 405, + TooManyRequests = 429, InternalServerError = 500, InvalidState = 1001, InvalidOAuthCode = 1002, InvalidToken = 1003, + InviteRequired = 1004, + InvalidTicket = 1005, + InvalidUsername = 1006, + UsernameTaken = 1007, + InvitesDisabled = 1008, + InviteLimitReached = 1009, + InviteAlreadyUsed = 1010, UserNotFound = 2001, + + MemberNotFound = 3001, + MemberLimitReached = 3002, + + RequestTooBig = 4001, } export interface SignupRequest { diff --git a/frontend/package.json b/frontend/package.json index 9c5b32b..040f339 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "eslint": "8.19.0", "eslint-config-next": "12.2.2", "postcss": "^8.4.14", + "prettier": "2.7.1", "tailwindcss": "^3.1.6", "typescript": "4.7.4" } diff --git a/frontend/pages/_error.js b/frontend/pages/_error.js index 49f0ea6..55c3486 100644 --- a/frontend/pages/_error.js +++ b/frontend/pages/_error.js @@ -16,10 +16,10 @@ * - https://reactjs.org/docs/error-boundaries.html */ -import * as Sentry from '@sentry/nextjs'; -import NextErrorComponent from 'next/error'; +import * as Sentry from "@sentry/nextjs"; +import NextErrorComponent from "next/error"; -const CustomErrorComponent = props => { +const CustomErrorComponent = (props) => { // If you're using a Nextjs version prior to 12.2.1, uncomment this to // compensate for https://github.com/vercel/next.js/issues/8592 // Sentry.captureUnderscoreErrorException(props); @@ -27,7 +27,7 @@ const CustomErrorComponent = props => { return ; }; -CustomErrorComponent.getInitialProps = async contextData => { +CustomErrorComponent.getInitialProps = async (contextData) => { // In case this is running in a serverless function, await this in order to give Sentry // time to send the error before the lambda exits await Sentry.captureUnderscoreErrorException(contextData); diff --git a/frontend/pages/edit/member/[member]/index.tsx b/frontend/pages/edit/member/[member]/index.tsx index b8ba6de..be2972b 100644 --- a/frontend/pages/edit/member/[member]/index.tsx +++ b/frontend/pages/edit/member/[member]/index.tsx @@ -1,3 +1,3 @@ export default function EditMember() { - return <>Editing a member!; -} \ No newline at end of file + return <>Editing a member!; +} diff --git a/frontend/pages/edit/member/index.tsx b/frontend/pages/edit/member/index.tsx index 913d038..9a2f6c6 100644 --- a/frontend/pages/edit/member/index.tsx +++ b/frontend/pages/edit/member/index.tsx @@ -3,10 +3,10 @@ import { useEffect } from "react"; import Loading from "../../../components/Loading"; export default function Redirect() { - const router = useRouter(); - useEffect(() => { - router.push("/") - }, []) + const router = useRouter(); + useEffect(() => { + router.push("/"); + }, []); - return ; -} \ No newline at end of file + return ; +} diff --git a/frontend/pages/edit/profile.tsx b/frontend/pages/edit/profile.tsx index e6ae83b..51904d7 100644 --- a/frontend/pages/edit/profile.tsx +++ b/frontend/pages/edit/profile.tsx @@ -9,156 +9,162 @@ import cloneDeep from "lodash/cloneDeep"; import { ReactSortable } from "react-sortablejs"; import Card from "../../components/Card"; -import { EditableCard, EditField, PronounChoice } from "../../components/Editable"; +import { + EditableCard, + EditField, + PronounChoice, +} from "../../components/Editable"; export default function Index() { - const [user, setUser] = useRecoilState(userState); - const router = useRouter(); - - useEffect(() => { - if (!user) { - router.push("/"); - } - }, [user]) + const [user, setUser] = useRecoilState(userState); + const router = useRouter(); + useEffect(() => { if (!user) { - return ; + router.push("/"); } + }, [user]); - const [state, setState] = useState(cloneDeep(user)); + if (!user) { + return ; + } - const originalOrder = state.fields ? state.fields.map((f, i) => { + const [state, setState] = useState(cloneDeep(user)); + + const originalOrder = state.fields + ? state.fields.map((f, i) => { const field: EditField = { - id: i, - name: f.name, - pronouns: {}, + id: i, + name: f.name, + pronouns: {}, }; f.favourite?.forEach((val) => { - field.pronouns[val] = PronounChoice.favourite; + field.pronouns[val] = PronounChoice.favourite; }); f.okay?.forEach((val) => { - field.pronouns[val] = PronounChoice.okay; + field.pronouns[val] = PronounChoice.okay; }); f.jokingly?.forEach((val) => { - field.pronouns[val] = PronounChoice.jokingly; + field.pronouns[val] = PronounChoice.jokingly; }); f.friends_only?.forEach((val) => { - field.pronouns[val] = PronounChoice.friendsOnly; + field.pronouns[val] = PronounChoice.friendsOnly; }); f.avoid?.forEach((val) => { - field.pronouns[val] = PronounChoice.avoid; + field.pronouns[val] = PronounChoice.avoid; }); return field; - }) : []; + }) + : []; - const [fields, setFields] = useState(cloneDeep(originalOrder)); - const fieldsUpdated = !fieldsEqual(fields, originalOrder); + const [fields, setFields] = useState(cloneDeep(originalOrder)); + const fieldsUpdated = !fieldsEqual(fields, originalOrder); - return ( -
-
{`fieldsUpdated: ${fieldsUpdated}`}
- {/* @ts-ignore: This component isn't updated to have a "children" prop yet, but it accepts it just fine. */} - - {fields.map((field, i) => ( - { - field.name = e.target.value; - setFields([...fields]); - }} - onChangeFavourite={(e, entry: string) => { - field.pronouns[entry] = PronounChoice.favourite; - setFields([...fields]); - }} - onChangeOkay={(e, entry: string) => { - field.pronouns[entry] = PronounChoice.okay; - setFields([...fields]); - }} - onChangeJokingly={(e, entry: string) => { - field.pronouns[entry] = PronounChoice.jokingly; - setFields([...fields]); - }} - onChangeFriends={(e, entry: string) => { - field.pronouns[entry] = PronounChoice.friendsOnly; - setFields([...fields]); - }} - onChangeAvoid={(e, entry: string) => { - field.pronouns[entry] = PronounChoice.avoid; - setFields([...fields]); - }} - onClickDelete={(_) => { - const newFields = [...fields]; - newFields.splice(i, 1); - setFields(newFields); - }} - /> - ))} - -
- ); + return ( +
+
{`fieldsUpdated: ${fieldsUpdated}`}
+ {/* @ts-ignore: This component isn't updated to have a "children" prop yet, but it accepts it just fine. */} + + {fields.map((field, i) => ( + { + field.name = e.target.value; + setFields([...fields]); + }} + onChangeFavourite={(e, entry: string) => { + field.pronouns[entry] = PronounChoice.favourite; + setFields([...fields]); + }} + onChangeOkay={(e, entry: string) => { + field.pronouns[entry] = PronounChoice.okay; + setFields([...fields]); + }} + onChangeJokingly={(e, entry: string) => { + field.pronouns[entry] = PronounChoice.jokingly; + setFields([...fields]); + }} + onChangeFriends={(e, entry: string) => { + field.pronouns[entry] = PronounChoice.friendsOnly; + setFields([...fields]); + }} + onChangeAvoid={(e, entry: string) => { + field.pronouns[entry] = PronounChoice.avoid; + setFields([...fields]); + }} + onClickDelete={(_) => { + const newFields = [...fields]; + newFields.splice(i, 1); + setFields(newFields); + }} + /> + ))} + +
+ ); } function fieldsEqual(arr1: EditField[], arr2: EditField[]) { - if (arr1?.length !== arr2?.length) return false; + if (arr1?.length !== arr2?.length) return false; - if (!arr1.every((_, i) => arr1[i].id === arr2[i].id)) return false; + if (!arr1.every((_, i) => arr1[i].id === arr2[i].id)) return false; - return arr1.every((_, i) => - Object.keys(arr1[i].pronouns).every( - (val) => arr1[i].pronouns[val] === arr2[i].pronouns[val] - ) - ); + return arr1.every((_, i) => + Object.keys(arr1[i].pronouns).every( + (val) => arr1[i].pronouns[val] === arr2[i].pronouns[val] + ) + ); } async function updateUser(args: { - displayName: string; - bio: string; - fields: EditField[]; + displayName: string; + bio: string; + fields: EditField[]; }) { - const newFields = args.fields.map((editField) => { - const field: Field = { - name: editField.name, - favourite: [], - okay: [], - jokingly: [], - friends_only: [], - avoid: [], - }; + const newFields = args.fields.map((editField) => { + const field: Field = { + name: editField.name, + favourite: [], + okay: [], + jokingly: [], + friends_only: [], + avoid: [], + }; - Object.keys(editField).forEach((pronoun) => { - switch (editField.pronouns[pronoun]) { - case PronounChoice.favourite: - field.favourite?.push(pronoun); - break; - case PronounChoice.okay: - field.okay?.push(pronoun); - break; - case PronounChoice.jokingly: - field.jokingly?.push(pronoun); - break; - case PronounChoice.friendsOnly: - field.friends_only?.push(pronoun); - break; - case PronounChoice.avoid: - field.avoid?.push(pronoun); - break; - } - }); - - return field; + Object.keys(editField).forEach((pronoun) => { + switch (editField.pronouns[pronoun]) { + case PronounChoice.favourite: + field.favourite?.push(pronoun); + break; + case PronounChoice.okay: + field.okay?.push(pronoun); + break; + case PronounChoice.jokingly: + field.jokingly?.push(pronoun); + break; + case PronounChoice.friendsOnly: + field.friends_only?.push(pronoun); + break; + case PronounChoice.avoid: + field.avoid?.push(pronoun); + break; + } }); - return await fetchAPI("/users/@me", "PATCH", { - display_name: args.displayName, - bio: args.bio, - fields: newFields, - }); + return field; + }); + + return await fetchAPI("/users/@me", "PATCH", { + display_name: args.displayName, + bio: args.bio, + fields: newFields, + }); } diff --git a/frontend/pages/login/discord.tsx b/frontend/pages/login/discord.tsx index ce29552..3974eff 100644 --- a/frontend/pages/login/discord.tsx +++ b/frontend/pages/login/discord.tsx @@ -5,6 +5,9 @@ import fetchAPI from "../../lib/fetch"; import { userState } from "../../lib/state"; import { APIError, MeUser, SignupResponse } from "../../lib/types"; import TextInput from "../../components/TextInput"; +import Loading from "../../components/Loading"; +import { stat } from "fs"; +import Button, { ButtonStyle } from "../../components/Button"; interface CallbackResponse { has_account: boolean; @@ -41,41 +44,47 @@ export default function Discord() { error: null, requireInvite: false, }); - const [formData, setFormData] = useState<{ username: string, invite: string }>({ username: "", invite: "" }); + const [formData, setFormData] = useState<{ + username: string; + invite: string; + }>({ username: "", invite: "" }); useEffect(() => { - if (!router.query.code || !router.query.state) { return; } + if (!router.query.code || !router.query.state) { + return; + } + if (state.ticket || state.token) { + return; + } - fetchAPI( - "/auth/discord/callback", - "POST", - { - callback_domain: window.location.origin, - code: router.query.code, - state: router.query.state, - } - ).then(resp => { - setState({ - hasAccount: resp.has_account, - isLoading: false, - token: resp.token || null, - user: resp.user || null, - discord: resp.discord || null, - ticket: resp.ticket || null, - requireInvite: resp.require_invite, - }) - }).catch(e => { - setState({ - hasAccount: false, - isLoading: false, - error: e, - token: null, - user: null, - discord: null, - ticket: null, - requireInvite: false, - }); + fetchAPI("/auth/discord/callback", "POST", { + callback_domain: window.location.origin, + code: router.query.code, + state: router.query.state, }) + .then((resp) => { + setState({ + hasAccount: resp.has_account, + isLoading: false, + token: resp.token || null, + user: resp.user || null, + discord: resp.discord || null, + ticket: resp.ticket || null, + requireInvite: resp.require_invite, + }); + }) + .catch((e) => { + setState({ + hasAccount: false, + isLoading: false, + error: e, + token: null, + user: null, + discord: null, + ticket: null, + requireInvite: false, + }); + }); // we got a token + user, save it and return to the home page if (state.token) { @@ -86,14 +95,29 @@ export default function Discord() { } }, [state.token, state.user, setState, router]); + if (!state.ticket && !state.error) { + return ; + } else if (state.error) { + return ( +
+

Error: {state.error.message ?? state.error}

+

Try again?

+
+ ); + } + // user needs to create an account const signup = async () => { try { - const resp = await fetchAPI("/auth/discord/signup", "POST", { - ticket: state.ticket, - username: formData.username, - invite_code: formData.invite, - }); + const resp = await fetchAPI( + "/auth/discord/signup", + "POST", + { + ticket: state.ticket, + username: formData.username, + invite_code: formData.invite, + } + ); setUser(resp.user); localStorage.setItem("pronouns-token", resp.token); @@ -104,33 +128,46 @@ export default function Discord() { } }; - return <> -

Get started

-

You{"'"}ve logged in with Discord as {state.discord}.

+ return ( + <> +

Get started

+

+ You{"'"}ve logged in with Discord as{" "} + {state.discord}. +

- {state.error && ( -
-

Error: {state.error.message ?? state.error}

-

Try again?

-
- )} + {state.error && ( +
+

Error: {state.error.message ?? state.error}

+

Try again?

+
+ )} - - {state.requireInvite && ( - )} - - ; + {state.requireInvite && ( + + )} + + + ); } diff --git a/frontend/pages/u/[user]/index.tsx b/frontend/pages/u/[user]/index.tsx index 42e8126..dc7dea3 100644 --- a/frontend/pages/u/[user]/index.tsx +++ b/frontend/pages/u/[user]/index.tsx @@ -11,7 +11,13 @@ import { useRecoilValue } from "recoil"; import Link from "next/link"; import FallbackImage from "../../../components/FallbackImage"; import { ReactNode } from "react"; -import { EmojiLaughing, HandThumbsDown, HandThumbsUp, HeartFill, People } from "react-bootstrap-icons"; +import { + EmojiLaughing, + HandThumbsDown, + HandThumbsUp, + HeartFill, + People, +} from "react-bootstrap-icons"; interface Props { user: User; @@ -54,10 +60,11 @@ export default function Index({ user }: Props) {

{user.display_name}

)}

@{user.username}

@@ -82,12 +89,20 @@ export default function Index({ user }: Props) { )} - {user.names?.length > 0 &&
- {user.names.map((name, index) => )} -
} - {user.pronouns?.length > 0 &&
- {user.pronouns.map((pronoun, index) => )} -
} + {user.names?.length > 0 && ( +
+ {user.names.map((name, index) => ( + + ))} +
+ )} + {user.pronouns?.length > 0 && ( +
+ {user.pronouns.map((pronoun, index) => ( + + ))} +
+ )}
{user.fields?.map((field, index) => ( @@ -112,30 +127,44 @@ const entryIcon = (status: WordStatus) => { icon = ; break; case WordStatus.FriendsOnly: - icon = + icon = ; break; case WordStatus.Avoid: - icon = + icon = ; break; } return icon; -} +}; function NameEntry(props: { name: Name }) { const { name } = props; - return

- {entryIcon(name.status)} {name.name} -

+ return ( +

+ {entryIcon(name.status)} {name.name} +

+ ); } function PronounEntry(props: { pronoun: Pronoun }) { const { pronoun } = props; - return

- {entryIcon(pronoun.status)} {pronoun.display_text ?? pronoun.pronouns.split("/").slice(0, 2).join("/")} -

+ return ( +

+ {entryIcon(pronoun.status)}{" "} + {pronoun.display_text ?? + pronoun.pronouns.split("/").slice(0, 2).join("/")} +

+ ); } export const getServerSideProps: GetServerSideProps = async (context) => { diff --git a/frontend/sentry.client.config.js b/frontend/sentry.client.config.js index f2f2b43..e2b8fbc 100644 --- a/frontend/sentry.client.config.js +++ b/frontend/sentry.client.config.js @@ -2,12 +2,14 @@ // The config you add here will be used whenever a page is visited. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ -import * as Sentry from '@sentry/nextjs'; +import * as Sentry from "@sentry/nextjs"; const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ - dsn: SENTRY_DSN || 'https://91af8c15c9cf4153aa260b7f57457d8f@o575775.ingest.sentry.io/6390139', + dsn: + SENTRY_DSN || + "https://91af8c15c9cf4153aa260b7f57457d8f@o575775.ingest.sentry.io/6390139", // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 1.0, // ... diff --git a/frontend/sentry.server.config.js b/frontend/sentry.server.config.js index e6b5a74..d67186e 100644 --- a/frontend/sentry.server.config.js +++ b/frontend/sentry.server.config.js @@ -2,12 +2,14 @@ // The config you add here will be used whenever the server handles a request. // https://docs.sentry.io/platforms/javascript/guides/nextjs/ -import * as Sentry from '@sentry/nextjs'; +import * as Sentry from "@sentry/nextjs"; const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ - dsn: SENTRY_DSN || 'https://91af8c15c9cf4153aa260b7f57457d8f@o575775.ingest.sentry.io/6390139', + dsn: + SENTRY_DSN || + "https://91af8c15c9cf4153aa260b7f57457d8f@o575775.ingest.sentry.io/6390139", // Adjust this value in production, or use tracesSampler for greater control tracesSampleRate: 1.0, // ... diff --git a/frontend/yarn.lock b/frontend/yarn.lock index ed55887..9057577 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2379,6 +2379,11 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier@2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64" + integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"