remove old frontend

This commit is contained in:
Sam 2023-03-11 16:54:28 +01:00
parent 75f628c722
commit 4c8888ec0c
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
48 changed files with 0 additions and 5979 deletions

View file

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

38
frontend/.gitignore vendored
View file

@ -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

View file

@ -1 +0,0 @@
.next

View file

@ -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.

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>;
}

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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

View file

@ -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;
}

View file

@ -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 })
) ?? [];
}
}

View file

@ -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,
});

View file

@ -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();
}

View file

@ -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.

View file

@ -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);

View file

@ -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"
}
}

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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;

View file

@ -1,3 +0,0 @@
export default function EditMember() {
return <>Editing a member!</>;
}

View file

@ -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 />;
}

View file

@ -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" });
}
}

View file

@ -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;

View file

@ -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 &quot;Create
account&quot;, 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>
);
}

View file

@ -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 };
}
};

View file

@ -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 />;
}

View file

@ -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>
</>
);
}

View file

@ -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 };
}
};

View file

@ -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 };
}
};

View file

@ -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: {},
},
};

View file

@ -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

View file

@ -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
});

View file

@ -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

View file

@ -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
});

View file

@ -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.

View file

@ -1 +0,0 @@
*.md

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -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")],
};

View file

@ -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"]
}

File diff suppressed because it is too large Load diff