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