forked from mirrors/pronouns.cc
remove old frontend
This commit is contained in:
parent
75f628c722
commit
4c8888ec0c
48 changed files with 0 additions and 5979 deletions
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
38
frontend/.gitignore
vendored
38
frontend/.gitignore
vendored
|
@ -1,38 +0,0 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Sentry
|
||||
.sentryclirc
|
|
@ -1 +0,0 @@
|
|||
.next
|
|
@ -1,34 +0,0 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
|
@ -1,14 +0,0 @@
|
|||
import Link from "next/link";
|
||||
|
||||
export type Props = {
|
||||
to: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function BlueLink({ to, children }: Props) {
|
||||
return (
|
||||
<Link href={to} className="hover:underline text-sky-500 dark:text-sky-400">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
import { MouseEventHandler, ReactNode } from "react";
|
||||
|
||||
export enum ButtonStyle {
|
||||
primary,
|
||||
success,
|
||||
danger,
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
style?: ButtonStyle;
|
||||
bold?: boolean;
|
||||
disabled?: boolean;
|
||||
noRound?: 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 (
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
className={`bg-blue-500 dark:bg-blue-500 hover:bg-blue-700 hover:dark:bg-blue-800 p-2 ${
|
||||
!props.noRound && "rounded-md"
|
||||
} ${props.disabled && "cursor-not-allowed"} text-white`}
|
||||
>
|
||||
<span className={props.bold ? "font-bold" : ""}>{props.children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessButton(props: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
className={`bg-green-600 dark:bg-green-700 hover:bg-green-700 hover:dark:bg-green-800 p-2 ${
|
||||
!props.noRound && "rounded-md"
|
||||
} ${props.disabled && "cursor-not-allowed"} text-white`}
|
||||
>
|
||||
<span className={props.bold ? "font-bold" : ""}>{props.children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DangerButton(props: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
className={`bg-red-600 dark:bg-red-700 hover:bg-red-700 hover:dark:bg-red-800 p-2 ${
|
||||
!props.noRound && "rounded-md"
|
||||
} ${props.disabled && "cursor-not-allowed"} text-white`}
|
||||
>
|
||||
<span className={props.bold ? "font-bold" : ""}>{props.children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import React, { ReactNode } from "react";
|
||||
|
||||
export type Props = {
|
||||
children?: ReactNode | undefined;
|
||||
title: string;
|
||||
draggable?: boolean;
|
||||
footer?: ReactNode | undefined;
|
||||
};
|
||||
|
||||
export default function Card({ title, draggable, children, footer }: Props) {
|
||||
return (
|
||||
<div className="relative bg-slate-100 dark:bg-slate-700 rounded-md shadow">
|
||||
<h1
|
||||
className={`text-2xl p-2 border-b border-zinc-200 dark:border-slate-800${
|
||||
draggable && " handle hover:cursor-grab"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<div className="flex flex-col p-2">{children}</div>
|
||||
{footer && (
|
||||
<div className="border-t border-zinc-200 dark:border-slate-800">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import React from "react";
|
||||
|
||||
export default function Container(props: React.PropsWithChildren<{}>) {
|
||||
return <div className="m-2 lg:m-4">{props.children}</div>;
|
||||
}
|
|
@ -1,164 +0,0 @@
|
|||
import {
|
||||
EmojiLaughing,
|
||||
HandThumbsDown,
|
||||
HandThumbsUp,
|
||||
Heart,
|
||||
People,
|
||||
Plus,
|
||||
ThreeDotsVertical,
|
||||
Trash3,
|
||||
} from "react-bootstrap-icons";
|
||||
|
||||
import Card from "./Card";
|
||||
import TextInput from "./TextInput";
|
||||
import Button, { ButtonStyle } from "./Button";
|
||||
import { useState } from "react";
|
||||
import { WordStatus } from "../lib/api-fetch";
|
||||
import { ReactSortable } from "react-sortablejs";
|
||||
|
||||
export interface EditField {
|
||||
id: number;
|
||||
name: string;
|
||||
values: EditFieldValue[];
|
||||
}
|
||||
|
||||
export interface EditFieldValue {
|
||||
id: number;
|
||||
value: string;
|
||||
status: WordStatus;
|
||||
}
|
||||
|
||||
type EditableCardProps = {
|
||||
field: EditField;
|
||||
onChangeName: React.ChangeEventHandler<HTMLInputElement>;
|
||||
onChangePronoun: React.ChangeEventHandler<HTMLInputElement>;
|
||||
onAddPronoun(pronoun: string): void;
|
||||
onDeletePronoun(e: React.MouseEvent<HTMLButtonElement>, index: number): void;
|
||||
onChangeFavourite(
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
index: number
|
||||
): void;
|
||||
onChangeOkay(e: React.MouseEvent<HTMLButtonElement>, index: number): void;
|
||||
onChangeJokingly(e: React.MouseEvent<HTMLButtonElement>, index: number): void;
|
||||
onChangeFriends(e: React.MouseEvent<HTMLButtonElement>, index: number): void;
|
||||
onChangeAvoid(e: React.MouseEvent<HTMLButtonElement>, index: number): void;
|
||||
onClickDelete: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onChangeOrder(newState: EditFieldValue[]): void;
|
||||
};
|
||||
|
||||
export function EditableCard(props: EditableCardProps) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const footer = (
|
||||
<div className="flex flex-col justify-between space-y-2">
|
||||
<div className="flex justify-between items-center px-2 pt-2">
|
||||
<TextInput value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
<Button
|
||||
disabled={!input || input === ""}
|
||||
style={ButtonStyle.success}
|
||||
onClick={() => {
|
||||
if (!input || input === "") return;
|
||||
|
||||
props.onAddPronoun(input);
|
||||
setInput("");
|
||||
}}
|
||||
>
|
||||
<Plus aria-hidden className="inline" /> Add entry
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-between p-2 border-t border-zinc-200 dark:border-slate-800">
|
||||
<TextInput value={props.field.name} onChange={props.onChangeName} />
|
||||
<Button style={ButtonStyle.danger} onClick={props.onClickDelete}>
|
||||
<Trash3 aria-hidden className="inline" /> Delete field
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card title={props.field.name} draggable footer={footer}>
|
||||
<ReactSortable
|
||||
handle=".entry-handle"
|
||||
list={props.field.values}
|
||||
setList={props.onChangeOrder}
|
||||
>
|
||||
{props.field.values.map((value, index) => {
|
||||
return (
|
||||
<li className="flex justify-between my-1 items-center" key={index}>
|
||||
<ThreeDotsVertical className="entry-handle hover:cursor-grab" />
|
||||
<TextInput
|
||||
value={value.value}
|
||||
prevValue={value.value}
|
||||
onChange={props.onChangePronoun}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => props.onChangeFavourite(e, index)}
|
||||
className={`${
|
||||
value.status == WordStatus.Favourite
|
||||
? "bg-slate-500"
|
||||
: "bg-slate-600"
|
||||
} text-white hover:bg-slate-400 p-2`}
|
||||
>
|
||||
<Heart />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => props.onChangeOkay(e, index)}
|
||||
className={`${
|
||||
value.status == WordStatus.Okay
|
||||
? "bg-slate-500"
|
||||
: "bg-slate-600"
|
||||
} text-white hover:bg-slate-400 p-2`}
|
||||
>
|
||||
<HandThumbsUp />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => props.onChangeJokingly(e, index)}
|
||||
className={`${
|
||||
value.status == WordStatus.Jokingly
|
||||
? "bg-slate-500"
|
||||
: "bg-slate-600"
|
||||
} text-white hover:bg-slate-400 p-2`}
|
||||
>
|
||||
<EmojiLaughing />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => props.onChangeFriends(e, index)}
|
||||
className={`${
|
||||
value.status == WordStatus.FriendsOnly
|
||||
? "bg-slate-500"
|
||||
: "bg-slate-600"
|
||||
} text-white hover:bg-slate-400 p-2`}
|
||||
>
|
||||
<People />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => props.onChangeAvoid(e, index)}
|
||||
className={`${
|
||||
value.status == WordStatus.Avoid
|
||||
? "bg-slate-500"
|
||||
: "bg-slate-600"
|
||||
} text-white hover:bg-slate-400 p-2`}
|
||||
>
|
||||
<HandThumbsDown />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => props.onDeletePronoun(e, index)}
|
||||
className="bg-red-600 dark:bg-red-700 hover:bg-red-700 hover:dark:bg-red-800 p-2"
|
||||
>
|
||||
<Trash3 />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ReactSortable>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface Props extends HTMLAttributes<Props> {
|
||||
urls: string[];
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export default function FallbackImage({ urls, alt, className }: Props) {
|
||||
const fallbackUrl = urls.pop()!;
|
||||
urls.push(fallbackUrl);
|
||||
|
||||
return (
|
||||
<picture className={className}>
|
||||
{urls.length !== 0 &&
|
||||
urls.map((url, key) => {
|
||||
let contentType: string;
|
||||
if (url.endsWith(".webp")) {
|
||||
contentType = "image/webp";
|
||||
} else if (url.endsWith(".jpg") || url.endsWith(".jpeg")) {
|
||||
contentType = "image/jpeg";
|
||||
} else if (url.endsWith(".png")) {
|
||||
contentType = "image/png";
|
||||
} else {
|
||||
contentType = "application/octet-stream";
|
||||
}
|
||||
return (
|
||||
<source width={200} key={key} srcSet={url} type={contentType} />
|
||||
);
|
||||
})}
|
||||
<img width={200} src={fallbackUrl} alt={alt} className={className} />
|
||||
</picture>
|
||||
);
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import { ThreeDots } from "react-bootstrap-icons";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col pt-32 items-center">
|
||||
<ThreeDots size={64} className="animate-bounce" aria-hidden="true" />
|
||||
<span className="font-bold text-xl">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export interface Props {
|
||||
children?: ReactNode | undefined;
|
||||
href: string;
|
||||
plain?: boolean | undefined; // Do not wrap in <li></li>
|
||||
}
|
||||
|
||||
export default function NavItem(props: Props) {
|
||||
const ret = (
|
||||
<Link
|
||||
href={props.href}
|
||||
className="hover:text-sky-500 dark:hover:text-sky-400"
|
||||
>
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (props.plain) {
|
||||
return ret;
|
||||
}
|
||||
return <li>{ret}</li>;
|
||||
}
|
|
@ -1,124 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { List, MoonStars, Sun } from "react-bootstrap-icons";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRecoilState } from "recoil";
|
||||
|
||||
import { APIError, ErrorCode, fetchAPI, MeUser } from "../lib/api-fetch";
|
||||
import { themeState, userState } from "../lib/state";
|
||||
import Logo from "./logo";
|
||||
import NavItem from "./NavItem";
|
||||
|
||||
export default function Navigation() {
|
||||
const [user, setUser] = useRecoilState(userState);
|
||||
const [darkTheme, setDarkTheme] = useRecoilState(themeState);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) return;
|
||||
|
||||
fetchAPI<MeUser>("/users/@me").then(
|
||||
(res) => setUser(res),
|
||||
(err) => {
|
||||
console.log("fetching /users/@me", err);
|
||||
if (
|
||||
(err as APIError).code == ErrorCode.InvalidToken ||
|
||||
(err as APIError).code == ErrorCode.Forbidden
|
||||
) {
|
||||
localStorage.removeItem("pronouns-token");
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [user, setUser]);
|
||||
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDarkTheme(
|
||||
localStorage.theme === "dark" ||
|
||||
(!("theme" in localStorage) &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
);
|
||||
|
||||
if (darkTheme) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}, [darkTheme]);
|
||||
|
||||
const storeTheme = (useDarkTheme: boolean | null) => {
|
||||
if (useDarkTheme === null) {
|
||||
localStorage.removeItem("theme");
|
||||
} else {
|
||||
localStorage.setItem("theme", useDarkTheme ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
|
||||
const nav = user ? (
|
||||
<>
|
||||
<NavItem href={`/u/${user.name}`}>@{user.name}</NavItem>
|
||||
<NavItem href="/settings">Settings</NavItem>
|
||||
<NavItem href="/logout">Log out</NavItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<NavItem href="/login">Log in</NavItem>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white/75 dark:bg-slate-800/75 w-full backdrop-blur border-slate-200 dark:border-slate-700 border-b">
|
||||
<div className="max-w-8xl mx-auto">
|
||||
<div className="py-4 mx-4">
|
||||
<div className="flex items-center">
|
||||
<Link href="/" passHref>
|
||||
<Logo />
|
||||
</Link>
|
||||
<div className="ml-auto flex items-center">
|
||||
<nav className="hidden lg:flex">
|
||||
<ul className="flex space-x-4 font-bold">{nav}</ul>
|
||||
</nav>
|
||||
<div className="flex border-l border-slate-200 ml-4 pl-4 lg:ml-6 lg:pl-6 lg:mr-2 dark:border-slate-700 space-x-2 lg:space-x-4">
|
||||
<div
|
||||
onClick={() => {
|
||||
setDarkTheme(!darkTheme);
|
||||
storeTheme(!darkTheme);
|
||||
}}
|
||||
title={
|
||||
darkTheme ? "Switch to light mode" : "Switch to dark mode"
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{darkTheme ? (
|
||||
<Sun className="hover:text-sky-400" size={24} />
|
||||
) : (
|
||||
<MoonStars size={24} className="hover:text-sky-500" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
title="Show menu"
|
||||
className="cursor-pointer flex lg:hidden"
|
||||
>
|
||||
<List
|
||||
className="dark:hover:text-sky-400 hover:text-sky-500"
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav
|
||||
className={`lg:hidden p-4 border-slate-200 dark:border-slate-700 border-b ${
|
||||
showMenu ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<ul className="flex flex-col space-y-4 font-bold">{nav}</ul>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
import { ReactNode } from "react";
|
||||
import { ButtonStyle } from "./Button";
|
||||
|
||||
export type NoticeStyle = ButtonStyle;
|
||||
|
||||
export interface Props {
|
||||
header?: string;
|
||||
style?: NoticeStyle;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Notice(props: Props) {
|
||||
if (props.style === undefined) {
|
||||
return PrimaryNotice(props);
|
||||
}
|
||||
|
||||
switch (props.style) {
|
||||
case ButtonStyle.primary:
|
||||
return PrimaryNotice(props);
|
||||
case ButtonStyle.success:
|
||||
return SuccessNotice(props);
|
||||
case ButtonStyle.danger:
|
||||
return DangerNotice(props);
|
||||
}
|
||||
}
|
||||
|
||||
function PrimaryNotice(props: Props) {
|
||||
return (
|
||||
<div className="bg-blue-500 p-4 rounded-md border-blue-600 text-white">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessNotice(props: Props) {
|
||||
return (
|
||||
<div className="bg-green-600 dark:bg-green-700 p-4 rounded-md text-white border-green-700 dark:border-green-800">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DangerNotice(props: Props) {
|
||||
return (
|
||||
<div className="bg-red-600 dark:bg-red-700 rounded-md text-red-50 border-red-700 dark:border-red-800 max-w-lg">
|
||||
{props.header && (
|
||||
<h3 className="uppercase text-sm font-bold border-b border-red-700 dark:border-red-800 px-4 py-3">
|
||||
{props.header}
|
||||
</h3>
|
||||
)}
|
||||
<div className={props.header ? "px-4 pt-3 pb-4" : "p-4"}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,270 +0,0 @@
|
|||
import Head from "next/head";
|
||||
import React from "react";
|
||||
import {
|
||||
EmojiLaughing,
|
||||
HandThumbsDown,
|
||||
HandThumbsUp,
|
||||
HeartFill,
|
||||
People,
|
||||
} from "react-bootstrap-icons";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useRecoilValue } from "recoil";
|
||||
|
||||
import { Field, Label, LabelStatus, Person, User } from "../lib/api";
|
||||
import { userState } from "../lib/state";
|
||||
import BlueLink from "./BlueLink";
|
||||
import Card from "./Card";
|
||||
import FallbackImage from "./FallbackImage";
|
||||
|
||||
export default function PersonPage({ person }: { person: Person }) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title key="title">{`${person.fullHandle()} - pronouns.cc`}</title>
|
||||
</Head>
|
||||
<PersonHead person={person} />
|
||||
<IsOwnUserPageNotice person={person} />
|
||||
<div className="container mx-auto pb-[20vh]">
|
||||
<div
|
||||
className="
|
||||
m-2 p-2
|
||||
flex flex-col lg:flex-row
|
||||
justify-center lg:justify-start
|
||||
items-center lg:items-start
|
||||
lg:space-x-16
|
||||
space-y-4 lg:space-y-0
|
||||
border-b border-slate-200 dark:border-slate-700
|
||||
"
|
||||
>
|
||||
<PersonAvatar person={person} />
|
||||
<PersonInfo person={person} />
|
||||
</div>
|
||||
<LabelList content={person.names} />
|
||||
<LabelList content={person.pronouns} />
|
||||
<FieldCardGrid fields={person.fields} />
|
||||
{person instanceof User ? (
|
||||
<MemberList user={person} />
|
||||
) : (
|
||||
<BlueLink
|
||||
to={person.relativeURL()}
|
||||
>{`< ${person.display()}`}</BlueLink>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonHead({ person }: { person: Person }) {
|
||||
const { displayName, avatarUrls, bio, names, pronouns } = person;
|
||||
let description = "";
|
||||
const favNames = names.filter((x) => x.status === LabelStatus.Favourite);
|
||||
const favPronouns = pronouns.filter(
|
||||
(x) => x.status === LabelStatus.Favourite
|
||||
);
|
||||
if (favNames.length || favPronouns.length) {
|
||||
description = `${person.shortHandle()}${
|
||||
favNames.length
|
||||
? ` goes by ${favNames.map((x) => x.display()).join(", ")}`
|
||||
: ""
|
||||
}${favNames.length && favPronouns.length ? " and" : ""}${
|
||||
favPronouns.length
|
||||
? `uses ${favPronouns
|
||||
.map((x) => x.shortDisplay())
|
||||
.join(", ")} pronouns.`
|
||||
: ""
|
||||
}`;
|
||||
} else if (bio && bio !== "") {
|
||||
description = `${bio.slice(0, 500)}${bio.length > 500 ? "…" : ""}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<meta key="og:sitename" property="og:site_name" content="pronouns.cc" />
|
||||
<meta
|
||||
key="og:title"
|
||||
property="og:title"
|
||||
content={
|
||||
displayName
|
||||
? `${displayName} (${person.fullHandle()})`
|
||||
: person.fullHandle()
|
||||
}
|
||||
/>
|
||||
{avatarUrls && avatarUrls.length > 0 && (
|
||||
<meta key="og:image" property="og:image" content={avatarUrls[0]} />
|
||||
)}
|
||||
<meta
|
||||
key="og:description"
|
||||
property="og:description"
|
||||
content={description}
|
||||
/>
|
||||
<meta key="og:url" property="og:url" content={person.absoluteURL()} />
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
|
||||
function IsOwnUserPageNotice({ person }: { person: Person }) {
|
||||
return useRecoilValue(userState)?.id === person.id ? (
|
||||
<div className="lg:w-1/3 mx-auto bg-slate-100 dark:bg-slate-700 shadow rounded-md p-2">
|
||||
You are currently viewing your <b>public</b> user profile.
|
||||
<br />
|
||||
<BlueLink to="/edit/profile">Edit your profile</BlueLink>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberList({ user, className }: { user: User; className?: string }) {
|
||||
const partialMembers = user.partialMembers;
|
||||
return (
|
||||
<div className={`mx-auto flex-col items-center ${className || ""}`}>
|
||||
<h1 className="text-2xl">Members</h1>
|
||||
<ul>
|
||||
{partialMembers.map((partialMember) => (
|
||||
<li className='before:[content:"-_"]' key={partialMember.id}>
|
||||
<BlueLink to={`/u/${user.name}/${partialMember.name}`}>
|
||||
<span>{partialMember.displayName ?? partialMember.name}</span>
|
||||
</BlueLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonAvatar({ person }: { person: Person }) {
|
||||
const { displayName, name, avatarUrls } = person;
|
||||
return avatarUrls && avatarUrls.length !== 0 ? (
|
||||
<FallbackImage
|
||||
className="max-w-xs rounded-full"
|
||||
urls={avatarUrls}
|
||||
alt={`${displayName || name}'s avatar`}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonInfo({ person }: { person: Person }) {
|
||||
const { displayName, name, bio, links } = person;
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* name */}
|
||||
<h1 className="text-2xl font-bold">
|
||||
{displayName === null ? name : displayName}
|
||||
</h1>
|
||||
{/* handle */}
|
||||
<h3 className="text-xl font-light text-slate-600 dark:text-slate-400">
|
||||
{person.fullHandle()}
|
||||
</h3>
|
||||
{/* bio */}
|
||||
{bio && (
|
||||
<ReactMarkdown className="prose dark:prose-invert prose-slate">
|
||||
{bio}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
{/* links */}
|
||||
{links.length > 0 && (
|
||||
<div className="flex flex-col mx-auto lg:ml-auto">
|
||||
{links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link}
|
||||
rel="nofollow noopener noreferrer me"
|
||||
className="hover:underline text-sky-500 dark:text-sky-400"
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelList({ content }: { content: Label[] }) {
|
||||
return content.length > 0 ? (
|
||||
<div className="border-b border-slate-200 dark:border-slate-700">
|
||||
{content.map((label, index) => (
|
||||
<LabelLine key={index} label={label} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelStatusIcon({ status }: { status: LabelStatus }) {
|
||||
return React.createElement(
|
||||
{
|
||||
[LabelStatus.Favourite]: HeartFill,
|
||||
[LabelStatus.Okay]: HandThumbsUp,
|
||||
[LabelStatus.Jokingly]: EmojiLaughing,
|
||||
[LabelStatus.FriendsOnly]: People,
|
||||
[LabelStatus.Avoid]: HandThumbsDown,
|
||||
}[status],
|
||||
{ className: "inline" }
|
||||
);
|
||||
}
|
||||
|
||||
function LabelsLine({
|
||||
status,
|
||||
texts,
|
||||
}: {
|
||||
status: LabelStatus;
|
||||
texts: string[];
|
||||
}) {
|
||||
return !texts.length ? (
|
||||
<></>
|
||||
) : (
|
||||
<p
|
||||
className={`
|
||||
${status === LabelStatus.Favourite ? "text-lg font-bold" : ""}
|
||||
${
|
||||
status === LabelStatus.Avoid ? "text-slate-600 dark:text-slate-400" : ""
|
||||
}`}
|
||||
>
|
||||
<LabelStatusIcon status={status} /> {texts.join(", ")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelLine({ label }: { label: Label }) {
|
||||
return <LabelsLine status={label.status} texts={[label.display()]} />;
|
||||
}
|
||||
|
||||
function FieldCardGrid({ fields }: { fields: Field[] }) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-4 py-2 [&>*]:flex-1">
|
||||
{fields.map((field, index) => (
|
||||
<FieldCard field={field} key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStatusOrder: LabelStatus[] = [
|
||||
LabelStatus.Favourite,
|
||||
LabelStatus.Okay,
|
||||
LabelStatus.Jokingly,
|
||||
LabelStatus.FriendsOnly,
|
||||
LabelStatus.Avoid,
|
||||
];
|
||||
|
||||
function FieldCard({
|
||||
field,
|
||||
draggable,
|
||||
}: {
|
||||
field: Field;
|
||||
draggable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card title={field.name} draggable={draggable}>
|
||||
{labelStatusOrder.map((status, i) =>
|
||||
field.labels
|
||||
.filter((x) => x.status === status)
|
||||
.map((x) => <LabelLine key={i} label={x} />)
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
import { ChangeEventHandler } from "react";
|
||||
|
||||
export type Props = {
|
||||
contrastBackground?: boolean;
|
||||
prevValue?: string;
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export default function TextInput(props: Props) {
|
||||
const bg = props.contrastBackground
|
||||
? "bg-slate-50 dark:bg-slate-700"
|
||||
: "bg-white dark:bg-slate-800";
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
data-prev-value={props.prevValue ?? props.value}
|
||||
className={`p-1 lg:p-2 rounded-md ${bg} border-slate-300 text-black dark:border-slate-900 dark:text-white`}
|
||||
defaultValue={props.defaultValue}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -1,132 +0,0 @@
|
|||
/** An array returned by the API. (Can be `null` due to a quirk in Go.) */
|
||||
export type Arr<T> = T[] | null;
|
||||
|
||||
export interface PartialPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
avatar_urls: Arr<string>;
|
||||
}
|
||||
|
||||
export type PartialUser = PartialPerson;
|
||||
|
||||
export type PartialMember = PartialPerson;
|
||||
|
||||
/** The shared interface of `Member` and `User`.
|
||||
* A typical `_Person` is only one of those two, so consider using `Person` instead.
|
||||
*/
|
||||
export interface _Person extends PartialPerson {
|
||||
bio: string | null;
|
||||
links: Arr<string>;
|
||||
names: Arr<FieldEntry>;
|
||||
pronouns: Arr<Pronoun>;
|
||||
fields: Arr<Field>;
|
||||
}
|
||||
|
||||
export interface User extends _Person {
|
||||
members: Arr<PartialMember>;
|
||||
}
|
||||
|
||||
export interface Member extends _Person {
|
||||
user: PartialUser;
|
||||
}
|
||||
|
||||
export type Person = Member | User;
|
||||
|
||||
export interface MeUser extends User {
|
||||
discord: string | null;
|
||||
discord_username: string | null;
|
||||
}
|
||||
|
||||
export interface Pronoun {
|
||||
display_text?: string;
|
||||
pronouns: string;
|
||||
status: WordStatus;
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
name: string;
|
||||
entries: Arr<FieldEntry>;
|
||||
}
|
||||
|
||||
export interface FieldEntry {
|
||||
value: string;
|
||||
status: WordStatus;
|
||||
}
|
||||
|
||||
export interface APIError {
|
||||
code: ErrorCode;
|
||||
message?: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export enum WordStatus {
|
||||
Favourite = 1,
|
||||
Okay = 2,
|
||||
Jokingly = 3,
|
||||
FriendsOnly = 4,
|
||||
Avoid = 5,
|
||||
}
|
||||
|
||||
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 {
|
||||
username: string;
|
||||
ticket: string;
|
||||
invite_code?: string;
|
||||
}
|
||||
|
||||
export interface SignupResponse {
|
||||
user: MeUser;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const apiBase = process.env.API_BASE ?? "/api";
|
||||
|
||||
export async function fetchAPI<T>(
|
||||
path: string,
|
||||
method = "GET",
|
||||
body: any = null
|
||||
) {
|
||||
const token =
|
||||
typeof localStorage !== "undefined" &&
|
||||
localStorage.getItem("pronouns-token");
|
||||
|
||||
const resp = await fetch(`${apiBase}/v1${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { Authorization: token } : {}),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
if (resp.status < 200 || resp.status >= 300) throw data as APIError;
|
||||
return data as T;
|
||||
}
|
|
@ -1,209 +0,0 @@
|
|||
import * as API from "./api-fetch";
|
||||
import { fetchAPI } from "./api-fetch";
|
||||
|
||||
function getDomain(): string {
|
||||
const domain =
|
||||
typeof window !== "undefined" ? window.location.origin : process.env.DOMAIN;
|
||||
if (!domain) throw new Error("process.env.DOMAIN not set");
|
||||
return domain;
|
||||
}
|
||||
|
||||
export class PartialPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string | null;
|
||||
avatarUrls: string[];
|
||||
constructor({ id, name, display_name, avatar_urls }: API.PartialPerson) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.displayName = display_name;
|
||||
this.avatarUrls = avatar_urls ?? [];
|
||||
}
|
||||
|
||||
display(): string {
|
||||
return this.displayName ?? this.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class PartialUser extends PartialPerson {}
|
||||
|
||||
export class PartialMember extends PartialPerson {}
|
||||
|
||||
abstract class _Person extends PartialPerson {
|
||||
bio: string | null;
|
||||
links: string[];
|
||||
names: Name[];
|
||||
pronouns: Pronouns[];
|
||||
fields: Field[];
|
||||
constructor(apiData: API._Person) {
|
||||
super(apiData);
|
||||
const { bio, links, names, pronouns, fields } = apiData;
|
||||
this.bio = bio;
|
||||
this.links = links ?? [];
|
||||
this.names = (names ?? []).map((x) => new Name(x));
|
||||
this.pronouns = (pronouns ?? []).map((x) => new Pronouns(x));
|
||||
this.fields = (fields ?? []).map((x) => new Field(x));
|
||||
}
|
||||
|
||||
abstract fullHandle(): string;
|
||||
|
||||
shortHandle(): string {
|
||||
return this.fullHandle();
|
||||
}
|
||||
|
||||
abstract relativeURL(): string;
|
||||
|
||||
absoluteURL(): string {
|
||||
return `${getDomain()}${this.relativeURL()}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class User extends _Person {
|
||||
partialMembers: PartialMember[];
|
||||
constructor(apiData: API.User) {
|
||||
super(apiData);
|
||||
const { members } = apiData;
|
||||
this.partialMembers = (members ?? []).map((x) => new PartialMember(x));
|
||||
}
|
||||
|
||||
static async fetchFromName(name: string): Promise<User> {
|
||||
return new User(await fetchAPI<API.User>(`/users/${name}`));
|
||||
}
|
||||
|
||||
fullHandle(): string {
|
||||
return `@${this.name}`;
|
||||
}
|
||||
|
||||
shortHandle(): string {
|
||||
return this.fullHandle();
|
||||
}
|
||||
|
||||
relativeURL(): string {
|
||||
return `/u/${this.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class Member extends _Person {
|
||||
partialUser: PartialUser;
|
||||
constructor(apiData: API.Member) {
|
||||
super(apiData);
|
||||
const { user } = apiData;
|
||||
this.partialUser = new PartialUser(user);
|
||||
}
|
||||
|
||||
static async fetchFromUserAndMemberName(
|
||||
userName: string,
|
||||
memberName: string
|
||||
): Promise<Member> {
|
||||
return new Member(
|
||||
await fetchAPI<API.Member>(`/users/${userName}/members/${memberName}`)
|
||||
);
|
||||
}
|
||||
|
||||
fullHandle(): string {
|
||||
return `${this.name}@${this.partialUser.name}`;
|
||||
}
|
||||
|
||||
relativeURL(): string {
|
||||
return `/u/${this.partialUser.name}/${this.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
export type Person = Member | User;
|
||||
|
||||
export class MeUser extends User {
|
||||
discord: string | null;
|
||||
discordUsername: string | null;
|
||||
constructor(apiData: API.MeUser) {
|
||||
super(apiData);
|
||||
const { discord, discord_username } = apiData;
|
||||
this.discord = discord;
|
||||
this.discordUsername = discord_username;
|
||||
}
|
||||
|
||||
static async fetchMe(): Promise<MeUser> {
|
||||
return new MeUser(await fetchAPI<API.MeUser>("/users/@me"));
|
||||
}
|
||||
}
|
||||
|
||||
export enum LabelType {
|
||||
Name = 1,
|
||||
Pronouns = 2,
|
||||
Unspecified = 3,
|
||||
}
|
||||
|
||||
export const LabelStatus = API.WordStatus;
|
||||
export type LabelStatus = API.WordStatus;
|
||||
|
||||
export interface LabelData {
|
||||
type?: LabelType;
|
||||
displayText: string | null;
|
||||
text: string;
|
||||
status: LabelStatus;
|
||||
}
|
||||
|
||||
export class Label {
|
||||
type: LabelType;
|
||||
displayText: string | null;
|
||||
text: string;
|
||||
status: LabelStatus;
|
||||
constructor({ type, displayText, text, status }: LabelData) {
|
||||
this.type = type ?? LabelType.Unspecified;
|
||||
this.displayText = displayText;
|
||||
this.text = text;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
display(): string {
|
||||
return this.displayText ?? this.text;
|
||||
}
|
||||
|
||||
shortDisplay(): string {
|
||||
return this.display();
|
||||
}
|
||||
}
|
||||
|
||||
export class Name extends Label {
|
||||
constructor({ value, status }: API.FieldEntry) {
|
||||
super({
|
||||
type: LabelType.Name,
|
||||
displayText: null,
|
||||
text: value,
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Pronouns extends Label {
|
||||
constructor({ display_text, pronouns, status }: API.Pronoun) {
|
||||
super({
|
||||
type: LabelType.Pronouns,
|
||||
displayText: display_text ?? null,
|
||||
text: pronouns,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
get pronouns(): string[] {
|
||||
return this.text.split("/");
|
||||
}
|
||||
set pronouns(to: string[]) {
|
||||
this.text = to.join("/");
|
||||
}
|
||||
|
||||
shortDisplay(): string {
|
||||
return this.displayText ?? this.pronouns.splice(0, 2).join("/");
|
||||
}
|
||||
}
|
||||
|
||||
export class Field {
|
||||
name: string;
|
||||
labels: Label[];
|
||||
constructor({ name, entries }: API.Field) {
|
||||
this.name = name;
|
||||
this.labels =
|
||||
entries?.map(
|
||||
(e) => new Label({ displayText: null, text: e.value, status: e.status })
|
||||
) ?? [];
|
||||
}
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
import { atom } from "recoil";
|
||||
import { MeUser } from "./api-fetch";
|
||||
|
||||
export const userState = atom<MeUser | null>({
|
||||
key: "userState",
|
||||
default: null,
|
||||
});
|
||||
|
||||
export const themeState = atom<boolean>({
|
||||
key: "themeState",
|
||||
default: false,
|
||||
});
|
|
@ -1,29 +0,0 @@
|
|||
import Toastify from "toastify-js";
|
||||
import "toastify-js/src/toastify.css";
|
||||
|
||||
export default function toast(options: { text: string; background?: string }) {
|
||||
let background: string;
|
||||
switch (options.background) {
|
||||
case "error":
|
||||
background = "#A1081F";
|
||||
break;
|
||||
case "success":
|
||||
background = "#1D611A";
|
||||
break;
|
||||
default:
|
||||
background = "#4F5859";
|
||||
break;
|
||||
}
|
||||
|
||||
Toastify({
|
||||
text: options.text,
|
||||
gravity: "top",
|
||||
position: "left",
|
||||
duration: -1,
|
||||
close: true,
|
||||
style: {
|
||||
background: background,
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
}).showToast();
|
||||
}
|
5
frontend/next-env.d.ts
vendored
5
frontend/next-env.d.ts
vendored
|
@ -1,5 +0,0 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
@ -1,28 +0,0 @@
|
|||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: "http://localhost:8080/:path*", // proxy to backend
|
||||
},
|
||||
{
|
||||
source: "/media/:path*",
|
||||
destination: "http://localhost:9000/pronouns.cc/:path*", // proxy to media server
|
||||
}
|
||||
];
|
||||
},
|
||||
sentry: {
|
||||
hideSourceMaps: true,
|
||||
},
|
||||
};
|
||||
|
||||
const sentryWebpackPluginOptions = {
|
||||
silent: true, // Suppresses all logs
|
||||
};
|
||||
|
||||
module.exports = withSentryConfig(nextConfig, sentryWebpackPluginOptions);
|
|
@ -1,50 +0,0 @@
|
|||
{
|
||||
"name": "pronouns",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "^6.0.5",
|
||||
"@sentry/nextjs": "^7.20.0",
|
||||
"@types/lodash": "^4.14.189",
|
||||
"@uiw/codemirror-theme-github": "^4.19.4",
|
||||
"@uiw/react-codemirror": "^4.15.1",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "13.0.4",
|
||||
"react": "18.2.0",
|
||||
"react-bootstrap-icons": "^1.8.4",
|
||||
"react-dom": "18.2.0",
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-sortablejs": "^6.1.4",
|
||||
"recoil": "^0.7.5",
|
||||
"sortablejs": "^1.15.0",
|
||||
"toastify-js": "^1.12.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/highlight": "0.19.8",
|
||||
"@codemirror/lang-markdown": "^6.0.5",
|
||||
"@codemirror/state": ">=6.0.0",
|
||||
"@codemirror/view": ">=6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.3",
|
||||
"@types/node": "18.0.3",
|
||||
"@types/react": "18.0.15",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/sortablejs": "^1.13.0",
|
||||
"@types/toastify-js": "^1.11.1",
|
||||
"autoprefixer": "^10.4.7",
|
||||
"eslint": "8.19.0",
|
||||
"eslint-config-next": "13.0.4",
|
||||
"openapi-typescript-codegen": "^0.23.0",
|
||||
"postcss": "^8.4.14",
|
||||
"prettier": "2.7.1",
|
||||
"tailwindcss": "^3.1.6",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
import "../styles/globals.css";
|
||||
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import { RecoilRoot } from "recoil";
|
||||
|
||||
import Container from "../components/Container";
|
||||
import Navigation from "../components/Navigation";
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
const domain =
|
||||
typeof window !== "undefined" ? window.location.origin : process.env.DOMAIN;
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Head>
|
||||
<title key="title">pronouns.cc</title>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#aa8ed6" />
|
||||
|
||||
<meta key="og:sitename" property="og:site_name" content="pronouns.cc" />
|
||||
<meta key="og:title" property="og:title" content="pronouns.cc" />
|
||||
<meta
|
||||
key="og:description"
|
||||
property="og:description"
|
||||
content="Name and pronoun cards!"
|
||||
/>
|
||||
<meta key="og:url" property="og:url" content={domain} />
|
||||
</Head>
|
||||
<Navigation />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</RecoilRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
|
@ -1,15 +0,0 @@
|
|||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html>
|
||||
<Head>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</Head>
|
||||
<body className="bg-white dark:bg-slate-800 text-black dark:text-white">
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* NOTE: This requires `@sentry/nextjs` version 7.3.0 or higher.
|
||||
*
|
||||
* NOTE: If using this with `next` version 12.2.0 or lower, uncomment the
|
||||
* penultimate line in `CustomErrorComponent`.
|
||||
*
|
||||
* This page is loaded by Nextjs:
|
||||
* - on the server, when data-fetching methods throw or reject
|
||||
* - on the client, when `getInitialProps` throws or rejects
|
||||
* - on the client, when a React lifecycle method throws or rejects, and it's
|
||||
* caught by the built-in Nextjs error boundary
|
||||
*
|
||||
* See:
|
||||
* - https://nextjs.org/docs/basic-features/data-fetching/overview
|
||||
* - https://nextjs.org/docs/api-reference/data-fetching/get-initial-props
|
||||
* - https://reactjs.org/docs/error-boundaries.html
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import NextErrorComponent from "next/error";
|
||||
|
||||
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);
|
||||
|
||||
return <NextErrorComponent statusCode={props.statusCode} />;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// This will contain the status code of the response
|
||||
return NextErrorComponent.getInitialProps(contextData);
|
||||
};
|
||||
|
||||
export default CustomErrorComponent;
|
|
@ -1,3 +0,0 @@
|
|||
export default function EditMember() {
|
||||
return <>Editing a member!</>;
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import Loading from "../../../components/Loading";
|
||||
|
||||
export default function Redirect() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.push("/");
|
||||
}, []);
|
||||
|
||||
return <Loading />;
|
||||
}
|
|
@ -1,269 +0,0 @@
|
|||
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
|
||||
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
||||
import ReactCodeMirror from "@uiw/react-codemirror";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Save, Trash } from "react-bootstrap-icons";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { ReactSortable } from "react-sortablejs";
|
||||
import { useRecoilState, useRecoilValue } from "recoil";
|
||||
|
||||
import Button, { ButtonStyle } from "../../components/Button";
|
||||
import {
|
||||
EditableCard,
|
||||
EditField,
|
||||
EditFieldValue,
|
||||
} from "../../components/Editable";
|
||||
import Loading from "../../components/Loading";
|
||||
import { fetchAPI, Field, MeUser, WordStatus } from "../../lib/api-fetch";
|
||||
import { themeState, userState } from "../../lib/state";
|
||||
import toast from "../../lib/toast";
|
||||
|
||||
export default function Index() {
|
||||
const [user, setUser] = useRecoilState(userState);
|
||||
const darkTheme = useRecoilValue(themeState);
|
||||
const router = useRouter();
|
||||
const [state, setState] = useState(cloneDeep(user));
|
||||
|
||||
const originalOrder = state?.fields
|
||||
? state.fields.map((f, i) => {
|
||||
const field: EditField = {
|
||||
id: i,
|
||||
name: f.name,
|
||||
values: [],
|
||||
};
|
||||
|
||||
f.entries?.forEach((entry, idx) => {
|
||||
field.values.push({ ...entry, id: idx });
|
||||
});
|
||||
|
||||
return field;
|
||||
})
|
||||
: [];
|
||||
|
||||
const [fields, setFields] = useState(cloneDeep(originalOrder));
|
||||
|
||||
const resetFields = () => {
|
||||
setFields(cloneDeep(originalOrder));
|
||||
};
|
||||
|
||||
const addField = () => {
|
||||
if (fields.length >= 25) return;
|
||||
|
||||
const lastId = fields[fields.length - 1]?.id ?? -1;
|
||||
|
||||
setFields([
|
||||
...fields,
|
||||
{ id: lastId + 1, name: `Field #${lastId + 2}`, values: [] },
|
||||
]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !state) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (!user || !state) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const fieldsUpdated = !fieldsEqual(fields, originalOrder);
|
||||
const isEdited = fieldsUpdated || state.bio !== user.bio;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<h1 className="p-2 border-b border-slate-300 dark:border-slate-600 flex items-center justify-between">
|
||||
<span className="text-3xl">Editing your profile</span>
|
||||
{isEdited && (
|
||||
<Button
|
||||
style={ButtonStyle.success}
|
||||
onClick={async () => {
|
||||
const user = await updateUser({
|
||||
displayName: state.display_name,
|
||||
bio: state.bio,
|
||||
fields,
|
||||
});
|
||||
|
||||
if (user) setUser(user);
|
||||
}}
|
||||
>
|
||||
<Save aria-hidden className="inline" /> Save changes
|
||||
</Button>
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<h3 className="p-2 border-b border-slate-300 dark:border-slate-600 flex items-center justify-between">
|
||||
<span className="text-xl">Bio</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 py-2">
|
||||
<div>
|
||||
<h4 className="text-lg font-bold">Edit</h4>
|
||||
<ReactCodeMirror
|
||||
className="text-base"
|
||||
value={state.bio || undefined}
|
||||
onChange={(val, _) => {
|
||||
setState({ ...state, bio: val });
|
||||
}}
|
||||
theme={darkTheme ? githubDark : githubLight}
|
||||
minHeight="200"
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
autocompletion: false,
|
||||
allowMultipleSelections: false,
|
||||
}}
|
||||
lang="markdown"
|
||||
extensions={[markdown({ base: markdownLanguage })]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-bold">Preview</h4>
|
||||
<ReactMarkdown>{state.bio || ""}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="p-2 border-b border-slate-300 dark:border-slate-600 flex items-center justify-between">
|
||||
<span className="text-xl">Fields</span>
|
||||
<div className="inline">
|
||||
<Button
|
||||
noRound
|
||||
style={ButtonStyle.success}
|
||||
onClick={() => addField()}
|
||||
>
|
||||
{" "}
|
||||
<Plus aria-hidden className="inline" />
|
||||
Add field
|
||||
</Button>
|
||||
{fieldsUpdated && (
|
||||
<Button
|
||||
noRound
|
||||
style={ButtonStyle.danger}
|
||||
onClick={() => resetFields()}
|
||||
>
|
||||
<Trash aria-hidden className="inline" />
|
||||
Reset fields
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</h3>
|
||||
<ReactSortable
|
||||
handle=".handle"
|
||||
list={fields}
|
||||
setList={setFields}
|
||||
className="grid grid-cols-1 xl:grid-cols-2 gap-4 py-2"
|
||||
>
|
||||
{fields.map((field, i) => (
|
||||
<EditableCard
|
||||
key={i}
|
||||
field={field}
|
||||
onChangePronoun={(e) => {
|
||||
const prev =
|
||||
e.target.attributes.getNamedItem("data-prev-value")?.value;
|
||||
if (!prev || !e.target.value) return;
|
||||
|
||||
const idx = field.values.findIndex((val) => val.value === prev);
|
||||
if (idx !== -1) {
|
||||
field.values[idx].value = e.target.value;
|
||||
}
|
||||
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onAddPronoun={(pronoun) => {
|
||||
field.values.push({
|
||||
id: field.values.length + 1,
|
||||
value: pronoun,
|
||||
status: WordStatus.Okay,
|
||||
});
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onChangeOrder={(newState: EditFieldValue[]) => {
|
||||
field.values = newState;
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onDeletePronoun={(e, index) => {
|
||||
delete field.values[index];
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onChangeName={(e) => {
|
||||
field.name = e.target.value;
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onChangeFavourite={(e, index) => {
|
||||
field.values[index].status = WordStatus.Favourite;
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onChangeOkay={(e, index) => {
|
||||
field.values[index].status = WordStatus.Okay;
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onChangeJokingly={(e, index) => {
|
||||
field.values[index].status = WordStatus.Jokingly;
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onChangeFriends={(e, index) => {
|
||||
field.values[index].status = WordStatus.FriendsOnly;
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onChangeAvoid={(e, index) => {
|
||||
field.values[index].status = WordStatus.Avoid;
|
||||
setFields([...fields]);
|
||||
}}
|
||||
onClickDelete={(_) => {
|
||||
const newFields = [...fields];
|
||||
newFields.splice(i, 1);
|
||||
setFields(newFields);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ReactSortable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fieldsEqual(arr1: EditField[], arr2: EditField[]) {
|
||||
if (arr1?.length !== arr2?.length) return false;
|
||||
|
||||
if (!arr1.every((_, i) => arr1[i].id === arr2[i].id)) return false;
|
||||
|
||||
return arr1.every((_, i) =>
|
||||
arr1[i].values.every(
|
||||
(val, j) =>
|
||||
val.value === arr2[i].values[j].value &&
|
||||
val.status === arr2[i].values[j].status
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function updateUser(args: {
|
||||
displayName: string | null;
|
||||
bio: string | null;
|
||||
fields: EditField[];
|
||||
}) {
|
||||
const newFields = args.fields.map((editField) => {
|
||||
const field: Field = {
|
||||
name: editField.name,
|
||||
entries: [],
|
||||
};
|
||||
|
||||
field.entries = [...editField.values];
|
||||
|
||||
return field;
|
||||
});
|
||||
|
||||
try {
|
||||
const user = await fetchAPI<MeUser>("/users/@me", "PATCH", {
|
||||
display_name: args.displayName ?? null,
|
||||
bio: args.bio ?? null,
|
||||
fields: newFields,
|
||||
});
|
||||
|
||||
toast({ text: "Successfully updated your profile!" });
|
||||
|
||||
return user;
|
||||
} catch (e: any) {
|
||||
toast({ text: `${e.details ?? e.message ?? e}`, background: "error" });
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
// this is a temporary home page, which is why the markdown content is embedded
|
||||
const md = `This will (one day) be a site to create pronoun cards for yourself,
|
||||
similarly to [Pronouny](https://pronouny.xyz/) and [Pronouns.page](https://en.pronouns.page/).
|
||||
|
||||
You'll be able to create multiple profiles that are linked together,
|
||||
useful for plurality ([what?](https://morethanone.info/)) and kin, or even just for fun!
|
||||
|
||||
For now though, there's just this landing page <3
|
||||
(And no, the "Log in" button doesn't do anything either.)
|
||||
|
||||
Check out the (work in progress) source code on [Codeberg](https://codeberg.org/u1f320/pronouns.cc)!`;
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div className="prose prose-slate dark:prose-invert">
|
||||
<ReactMarkdown>{md}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
|
@ -1,198 +0,0 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRecoilState } from "recoil";
|
||||
|
||||
import BlueLink from "../../components/BlueLink";
|
||||
import Button, { ButtonStyle } from "../../components/Button";
|
||||
import Loading from "../../components/Loading";
|
||||
import Notice from "../../components/Notice";
|
||||
import TextInput from "../../components/TextInput";
|
||||
import { fetchAPI, MeUser, SignupResponse } from "../../lib/api-fetch";
|
||||
import { userState } from "../../lib/state";
|
||||
import toast from "../../lib/toast";
|
||||
|
||||
interface CallbackResponse {
|
||||
has_account: boolean;
|
||||
token?: string;
|
||||
user?: MeUser;
|
||||
|
||||
discord?: string;
|
||||
ticket?: string;
|
||||
require_invite: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasAccount: boolean;
|
||||
isLoading: boolean;
|
||||
token: string | null;
|
||||
user: MeUser | null;
|
||||
discord: string | null;
|
||||
ticket: string | null;
|
||||
error?: any;
|
||||
requireInvite: boolean;
|
||||
}
|
||||
|
||||
export default function Discord() {
|
||||
const router = useRouter();
|
||||
|
||||
const [user, setUser] = useRecoilState(userState);
|
||||
const [state, setState] = useState<State>({
|
||||
hasAccount: false,
|
||||
isLoading: false,
|
||||
token: null,
|
||||
user: null,
|
||||
discord: null,
|
||||
ticket: null,
|
||||
error: null,
|
||||
requireInvite: false,
|
||||
});
|
||||
const [formData, setFormData] = useState<{
|
||||
username: string;
|
||||
invite: string;
|
||||
}>({ username: "", invite: "" });
|
||||
|
||||
useEffect(() => {
|
||||
if (state.isLoading || !router.query.code || !router.query.state) {
|
||||
return;
|
||||
}
|
||||
|
||||
// we got a token + user, save it and return to the home page
|
||||
if (state.token) {
|
||||
window.localStorage.setItem("pronouns-token", state.token);
|
||||
setUser(state.user!);
|
||||
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ ...state, isLoading: true });
|
||||
fetchAPI<CallbackResponse>("/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,
|
||||
});
|
||||
});
|
||||
|
||||
if (!state.ticket && !state.token) {
|
||||
return;
|
||||
}
|
||||
}, [router.query.code, router.query.state, state.token]);
|
||||
|
||||
if (state.isLoading || (!state.ticket && !state.error)) {
|
||||
return <Loading />;
|
||||
} else if (!state.ticket && state.error) {
|
||||
return (
|
||||
<Notice style={ButtonStyle.danger} header="Login error">
|
||||
<p>{state.error.message ?? state.error}</p>
|
||||
<p>Try again?</p>
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
|
||||
// user needs to create an account
|
||||
const signup = async () => {
|
||||
try {
|
||||
const resp = await fetchAPI<SignupResponse>(
|
||||
"/auth/discord/signup",
|
||||
"POST",
|
||||
{
|
||||
ticket: state.ticket,
|
||||
username: formData.username,
|
||||
invite_code: formData.invite,
|
||||
}
|
||||
);
|
||||
|
||||
setUser(resp.user);
|
||||
localStorage.setItem("pronouns-token", resp.token);
|
||||
|
||||
toast({ text: "Created account!", background: "success" });
|
||||
router.push("/");
|
||||
} catch (e: any) {
|
||||
toast({ text: `${e.message ?? e}`, background: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-slate-200 dark:border-slate-700 border rounded max-w-xl">
|
||||
<div className="border-b border-slate-200 dark:border-slate-700 p-2">
|
||||
<h1 className="font-bold text-xl">Get started</h1>
|
||||
</div>
|
||||
<div className="px-2 pt-2">
|
||||
<p>
|
||||
Just one more thing!
|
||||
<br />
|
||||
<strong className="font-bold">{state.discord}</strong>, you need to
|
||||
choose a username
|
||||
{state.requireInvite && " and fill in an invite code"} before you
|
||||
create an account!
|
||||
</p>
|
||||
</div>
|
||||
<label className="block px-2 pb-3 pt-2">
|
||||
<span className="block font-bold p-2 text-slate-800 dark:text-slate-200">
|
||||
Username
|
||||
</span>
|
||||
<TextInput
|
||||
contrastBackground
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{state.requireInvite && (
|
||||
<label className="block px-2 pb-3 pt-2">
|
||||
<span className="block p-2 text-slate-800 dark:text-slate-200">
|
||||
Invite code <span className="font-bold">Invite code</span>
|
||||
<span className="font-italic">
|
||||
(an existing user can give you this!)
|
||||
</span>
|
||||
</span>
|
||||
<TextInput
|
||||
contrastBackground
|
||||
value={formData.invite}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, invite: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="bg-slate-100 dark:bg-slate-700 border-t border-slate-200 dark:border-slate-700">
|
||||
<span className="block p-3">
|
||||
<Button style={ButtonStyle.success} onClick={() => signup()}>
|
||||
Create account
|
||||
</Button>
|
||||
</span>
|
||||
<span className="block px-3 pb-3">
|
||||
<span className="font-bold">Note:</span> by clicking "Create
|
||||
account", you agree to the{" "}
|
||||
<BlueLink to="/page/tos">terms of service</BlueLink> and the{" "}
|
||||
<BlueLink to="/page/privacy">privacy policy</BlueLink>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
import { GetServerSideProps } from "next";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRecoilValue } from "recoil";
|
||||
|
||||
import { fetchAPI } from "../../lib/api-fetch";
|
||||
import { userState } from "../../lib/state";
|
||||
|
||||
interface URLsResponse {
|
||||
discord: string;
|
||||
}
|
||||
|
||||
export default function Login({ urls }: { urls: URLsResponse }) {
|
||||
const router = useRouter();
|
||||
|
||||
if (useRecoilValue(userState) !== null) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title key="title">Login - pronouns.cc</title>
|
||||
</Head>
|
||||
<a href={urls.discord}>Login with Discord</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
try {
|
||||
const urls = await fetchAPI<URLsResponse>("/auth/urls", "POST", {
|
||||
callback_domain: process.env.DOMAIN,
|
||||
});
|
||||
|
||||
return { props: { urls } };
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
|
||||
return { notFound: true };
|
||||
}
|
||||
};
|
|
@ -1,20 +0,0 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useRecoilState } from "recoil";
|
||||
|
||||
import Loading from "../components/Loading";
|
||||
import { userState } from "../lib/state";
|
||||
|
||||
export default function Logout() {
|
||||
const router = useRouter();
|
||||
const [_, setUser] = useRecoilState(userState);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.removeItem("pronouns-token");
|
||||
setUser(null);
|
||||
|
||||
router.push("/");
|
||||
}, []);
|
||||
|
||||
return <Loading />;
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
import { readdirSync } from "fs";
|
||||
import { readFile } from "fs/promises";
|
||||
import { GetStaticProps } from "next";
|
||||
import Head from "next/head";
|
||||
import { join } from "path";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const names = readdirSync("./static_pages").filter((name) =>
|
||||
name.endsWith(".md")
|
||||
);
|
||||
|
||||
const paths = names.map((name) => ({
|
||||
params: { page: name.slice(0, -3) },
|
||||
}));
|
||||
|
||||
return {
|
||||
paths: paths,
|
||||
fallback: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps<{ text: string }> = async ({
|
||||
params,
|
||||
}) => {
|
||||
const text = await readFile(join("./static_pages", params!.page + ".md"));
|
||||
return { props: { text: text.toString("utf8") } };
|
||||
};
|
||||
|
||||
export default function Page(props: { text: string }) {
|
||||
const title = props.text.split("\n")[0].slice(2);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title key="title">{`${title} - pronouns.cc`}</title>
|
||||
</Head>
|
||||
<div className="prose prose-slate dark:prose-invert">
|
||||
<ReactMarkdown>{props.text}</ReactMarkdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
import { GetServerSideProps } from "next";
|
||||
|
||||
import PersonPage from "../../../components/PersonPage";
|
||||
import { Member } from "../../../lib/api";
|
||||
import * as API from "../../../lib/api-fetch";
|
||||
|
||||
interface Props {
|
||||
member: API.Member;
|
||||
}
|
||||
|
||||
export default function MemberPage({ member }: Props) {
|
||||
return <PersonPage person={new Member(member)} />;
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const userName = context.params!.user;
|
||||
if (typeof userName !== "string") return { notFound: true };
|
||||
const memberName = context.params!.member;
|
||||
if (typeof memberName !== "string") return { notFound: true };
|
||||
|
||||
try {
|
||||
return {
|
||||
props: {
|
||||
member: await API.fetchAPI<API.Member>(
|
||||
`/users/${context.params!.user}/members/${context.params!.member}`
|
||||
),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return { notFound: true };
|
||||
}
|
||||
};
|
|
@ -1,24 +0,0 @@
|
|||
import { GetServerSideProps } from "next";
|
||||
|
||||
import PersonPage from "../../../components/PersonPage";
|
||||
import { User } from "../../../lib/api";
|
||||
import * as API from "../../../lib/api-fetch";
|
||||
|
||||
interface Props {
|
||||
user: API.User;
|
||||
}
|
||||
|
||||
export default function Index({ user }: Props) {
|
||||
return <PersonPage person={new User(user)} />;
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const userName = context.params!.user;
|
||||
if (typeof userName !== "string") return { notFound: true };
|
||||
try {
|
||||
return { props: { user: await API.fetchAPI<User>(`/users/${userName}`) } };
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return { notFound: true };
|
||||
}
|
||||
};
|
|
@ -1,8 +0,0 @@
|
|||
// If you want to use other PostCSS plugins, see the following:
|
||||
// https://tailwindcss.com/docs/using-with-preprocessors
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 47.5 47.5" style="enable-background:new 0 0 47.5 47.5;" xml:space="preserve" version="1.1" id="svg2"><metadata id="metadata8"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><defs id="defs6"><clipPath id="clipPath16" clipPathUnits="userSpaceOnUse"><path id="path18" d="M 0,38 38,38 38,0 0,0 0,38 Z"/></clipPath></defs><g transform="matrix(1.25,0,0,-1.25,0,47.5)" id="g10"><g id="g12"><g clip-path="url(#clipPath16)" id="g14"><g transform="translate(35.3467,20.1069)" id="g20"><path id="path22" style="fill:#aa8ed6;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -8.899,3.294 -3.323,10.891 c -0.128,0.42 -0.516,0.708 -0.956,0.708 -0.439,0 -0.828,-0.288 -0.956,-0.708 L -17.456,3.294 -26.356,0 c -0.393,-0.146 -0.653,-0.52 -0.653,-0.938 0,-0.418 0.26,-0.793 0.653,-0.937 l 8.896,-3.293 3.323,-11.223 c 0.126,-0.425 0.516,-0.716 0.959,-0.716 0.443,0 0.833,0.291 0.959,0.716 l 3.324,11.223 8.896,3.293 c 0.392,0.144 0.652,0.519 0.652,0.937 C 0.653,-0.52 0.393,-0.146 0,0"/></g><g transform="translate(15.3472,9.1064)" id="g24"><path id="path26" style="fill:#fcab40;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -2.313,0.856 -0.9,3.3 c -0.119,0.436 -0.514,0.738 -0.965,0.738 -0.451,0 -0.846,-0.302 -0.965,-0.738 l -0.9,-3.3 L -8.356,0 c -0.393,-0.145 -0.653,-0.52 -0.653,-0.937 0,-0.418 0.26,-0.793 0.653,-0.938 l 2.301,-0.853 0.907,-3.622 c 0.111,-0.444 0.511,-0.756 0.97,-0.756 0.458,0 0.858,0.312 0.97,0.756 L -2.301,-2.728 0,-1.875 c 0.393,0.145 0.653,0.52 0.653,0.938 C 0.653,-0.52 0.393,-0.145 0,0"/></g><g transform="translate(11.0093,30.769)" id="g28"><path id="path30" style="fill:#5dadec;fill-opacity:1;fill-rule:nonzero;stroke:none" d="M 0,0 -2.365,0.875 -3.24,3.24 c -0.146,0.393 -0.52,0.653 -0.938,0.653 -0.419,0 -0.793,-0.26 -0.938,-0.653 L -5.992,0.875 -8.356,0 c -0.393,-0.146 -0.653,-0.52 -0.653,-0.938 0,-0.418 0.26,-0.792 0.653,-0.938 l 2.364,-0.875 0.876,-2.365 c 0.145,-0.393 0.519,-0.653 0.938,-0.653 0.418,0 0.792,0.26 0.938,0.653 L -2.365,-2.751 0,-1.876 c 0.393,0.146 0.653,0.52 0.653,0.938 C 0.653,-0.52 0.393,-0.146 0,0"/></g></g></g></g></svg>
|
Before Width: | Height: | Size: 2.4 KiB |
|
@ -1,19 +0,0 @@
|
|||
// This file configures the initialization of Sentry on the browser.
|
||||
// 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";
|
||||
|
||||
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",
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1.0,
|
||||
// ...
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
|
@ -1,4 +0,0 @@
|
|||
defaults.url=https://sentry.io/
|
||||
defaults.org=personal-bots
|
||||
defaults.project=pronounscc
|
||||
cli.executable=../../../.npm/_npx/a8388072043b4cbc/node_modules/@sentry/cli/bin/sentry-cli
|
|
@ -1,19 +0,0 @@
|
|||
// This file configures the initialization of Sentry on the server.
|
||||
// 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";
|
||||
|
||||
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",
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1.0,
|
||||
// ...
|
||||
// Note: if you want to override the automatic release value, do not set a
|
||||
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
||||
// that it will also get attached to your source maps
|
||||
});
|
|
@ -1,3 +0,0 @@
|
|||
Put any static pages here, in markdown format.
|
||||
|
||||
The frontend requires `tos.md` and `privacy.md`, but you can add others if you wish.
|
1
frontend/static_pages/.gitignore
vendored
1
frontend/static_pages/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
*.md
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
|
@ -1,11 +0,0 @@
|
|||
module.exports = {
|
||||
darkMode: "class",
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
|
||||
};
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
3714
frontend/yarn.lock
3714
frontend/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue