feat(frontend): start edit page

This commit is contained in:
Sam 2022-05-26 16:11:22 +02:00
parent 2ee1087eec
commit 8b31519952
15 changed files with 556 additions and 44 deletions

View file

@ -1,4 +1,4 @@
import { Routes, Route } from "react-router-dom";
import { Routes, Route, useParams } from "react-router-dom";
import "./App.css";
import Container from "./lib/Container";
import Navigation from "./lib/Navigation";
@ -15,8 +15,10 @@ function App() {
<Container>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/u/:username" element={<User />} />
<Route path="/@:username" element={<User />} />
<Route path="/@:username/:member" element={<User />} />
<Route path="/edit" element={<EditMe />} />
<Route path="/edit/:member" element={<EditMe />} />
<Route path="/login" element={<Login />} />
<Route path="/login/discord" element={<Discord />} />
</Routes>

View file

@ -0,0 +1,14 @@
import { Link } from "react-router-dom";
export type Props = {
to: string;
children?: React.ReactNode;
};
export default function BlueLink({ to, children }: Props) {
return (
<Link to={to} className="hover:underline text-sky-500 dark:text-sky-400">
{children}
</Link>
);
}

View file

@ -1,8 +1,13 @@
import React, { PropsWithChildren } from "react";
import React, { ReactNode, PropsWithChildren } from "react";
export type Props = PropsWithChildren<{ title: string; draggable?: boolean }>;
export type Props = {
children?: ReactNode | undefined;
title: string;
draggable?: boolean;
footer?: ReactNode | undefined;
};
export default function Card({ title, draggable, children }: Props) {
export default function Card({ title, draggable, children, footer }: Props) {
return (
<div className="bg-slate-100 dark:bg-slate-700 rounded-md shadow">
<h1
@ -13,6 +18,11 @@ export default function Card({ title, draggable, children }: Props) {
{title}
</h1>
<div className="flex flex-col p-2">{children}</div>
{footer && (
<div className="p-2 border-t border-zinc-200 dark:border-slate-800">
{footer}
</div>
)}
</div>
);
}

View file

@ -5,10 +5,24 @@ import {
People,
EmojiLaughing,
} from "react-bootstrap-icons";
import BlueLink from "./BlueLink";
import Card from "./Card";
import type { Field } from "./types";
function linkPronoun(input: string) {
if (input.includes(" ") || input.split("/").length !== 5)
return <span>{input}</span>;
const [sub, obj, possDet, possPro, reflexive] = input.split("/");
return (
<BlueLink to={`/pronouns/${sub}/${obj}/${possDet}/${possPro}/${reflexive}`}>
{sub}/{obj}/{possDet}
</BlueLink>
);
}
export default function FieldCard({
field,
draggable,
@ -20,7 +34,7 @@ export default function FieldCard({
<Card title={field.name} draggable={draggable}>
{field.favourite.map((entry) => (
<p className="text-lg font-bold">
<HeartFill className="inline" /> {entry}
<HeartFill className="inline" /> {linkPronoun(entry)}
</p>
))}
{field.okay.length !== 0 && (

View file

@ -0,0 +1,10 @@
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

@ -45,7 +45,7 @@ function Navigation() {
const nav = user ? (
<>
<NavItem to={`/u/${user.username}`}>@{user.username}</NavItem>
<NavItem to={`/@${user.username}`}>@{user.username}</NavItem>
<NavItem to="/settings">Settings</NavItem>
<NavItem to="/logout">Log out</NavItem>
</>

View file

@ -0,0 +1,19 @@
import { ChangeEventHandler } from "react";
export type Props = {
defaultValue?: string;
value?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
};
export default function TextInput(props: Props) {
return (
<input
type="text"
className="p-1 lg:p-2 rounded-md bg-white border-slate-300 text-black dark:bg-slate-800 dark:border-slate-900 dark:text-white"
defaultValue={props.defaultValue}
value={props.value}
onChange={props.onChange}
/>
);
}

View file

@ -33,6 +33,7 @@ export interface Field {
export interface APIError {
code: ErrorCode;
message?: string;
details?: string;
}
export enum ErrorCode {
@ -45,3 +46,9 @@ export enum ErrorCode {
UserNotFound = 2001,
}
export interface SignupRequest {
username: string;
ticket: string;
invite_code?: string;
}

View file

@ -1,38 +1,99 @@
import cloneDeep from "lodash/cloneDeep";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ReactSortable } from "react-sortablejs";
import { useRecoilValue } from "recoil";
import {
EmojiLaughing,
HandThumbsDown,
HandThumbsUp,
Heart,
People,
Trash3,
} from "react-bootstrap-icons";
import CreatableSelect from "react-select/creatable";
import { userState } from "../lib/store";
import { Field } from "../lib/types";
import FieldCard from "../lib/FieldCard";
import Loading from "../lib/Loading";
import Card from "../lib/Card";
import TextInput from "../lib/TextInput";
interface FieldWithID extends Field {
id: number;
}
interface EditField {
id: number;
name: string;
pronouns: Record<string, PronounChoice>;
}
enum PronounChoice {
favourite,
okay,
jokingly,
friendsOnly,
avoid,
}
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) =>
Object.keys(arr1[i].pronouns).every(
(val) => arr1[i].pronouns[val] === arr2[i].pronouns[val]
)
);
}
export default function EditMe() {
const navigate = useNavigate();
const meUser = useRecoilValue(userState);
useEffect(() => {
if (!meUser) {
navigate("/");
}
});
if (!meUser) {
navigate("/");
return <>Loading...</>;
return <Loading />;
}
const [state, setState] = useState(cloneDeep(meUser));
// add an ID to every field (not returned by the API normally, but Sortable needs it)
// convert all fields to EditFields
const originalOrder = state.fields.map((f, i) => {
const fID = f as FieldWithID;
fID.id = i;
return fID;
const field: EditField = {
id: i,
name: f.name,
pronouns: {},
};
f.favourite.forEach((val) => {
field.pronouns[val] = PronounChoice.favourite;
});
f.okay.forEach((val) => {
field.pronouns[val] = PronounChoice.okay;
});
f.jokingly.forEach((val) => {
field.pronouns[val] = PronounChoice.jokingly;
});
f.friends_only.forEach((val) => {
field.pronouns[val] = PronounChoice.friendsOnly;
});
f.avoid.forEach((val) => {
field.pronouns[val] = PronounChoice.avoid;
});
return field;
});
const [fields, setFields] = useState(cloneDeep(originalOrder));
const fieldsUpdated =
fields.length !== state.fields.length ||
!fields.every((_, i) => fields[i].id === originalOrder[i].id);
const fieldsUpdated = !fieldsEqual(fields, originalOrder);
return (
<div className="container mx-auto">
@ -42,12 +103,129 @@ export default function EditMe() {
handle=".handle"
list={fields}
setList={setFields}
className="grid grid-cols-1 md:grid-cols-3 gap-4 py-2"
className="grid grid-cols-1 xl:grid-cols-2 gap-4 py-2"
>
{fields.map((field, i) => (
<FieldCard key={i} field={field} draggable></FieldCard>
<EditableCard
key={i}
field={field}
onChangeName={(e) => {
field.name = e.target.value;
setFields([...fields]);
}}
onChangeFavourite={null}
onChangeOkay={null}
onChangeJokingly={null}
onChangeFriends={null}
onChangeAvoid={null}
onClickDelete={(_) => {
const newFields = [...fields];
newFields.splice(i, 1);
setFields(newFields);
}}
/>
))}
</ReactSortable>
</div>
);
}
type EditableCardProps = {
field: EditField;
onChangeName: React.ChangeEventHandler<HTMLInputElement>;
onChangeFavourite: any;
onChangeOkay: any;
onChangeJokingly: any;
onChangeFriends: any;
onChangeAvoid: any;
onClickDelete: React.MouseEventHandler<HTMLButtonElement>;
};
function EditableCard(props: EditableCardProps) {
const footer = (
<div className="flex justify-between">
<TextInput value={props.field.name} onChange={props.onChangeName} />
<button
type="button"
onClick={props.onClickDelete}
className="bg-red-600 dark:bg-red-700 hover:bg-red-700 hover:dark:bg-red-800 p-2 rounded-md"
>
<Trash3 aria-hidden className="inline" />{" "}
<span className="font-bold">Delete</span>
</button>
</div>
);
return (
<Card title={props.field.name} draggable footer={footer}>
<ul>
{Object.keys(props.field.pronouns).map((pronoun, index) => {
const choice = props.field.pronouns[pronoun];
return (
<li className="flex justify-between my-1" key={index}>
<div>{pronoun}</div>
<div className="rounded-md">
<button
type="button"
className={`${
choice == PronounChoice.favourite
? "bg-slate-500"
: "bg-slate-600"
} hover:bg-slate-400 p-2`}
>
<Heart />
</button>
<button
type="button"
className={`${
choice == PronounChoice.okay
? "bg-slate-500"
: "bg-slate-600"
} hover:bg-slate-400 p-2`}
>
<HandThumbsUp />
</button>
<button
type="button"
className={`${
choice == PronounChoice.jokingly
? "bg-slate-500"
: "bg-slate-600"
} hover:bg-slate-400 p-2`}
>
<EmojiLaughing />
</button>
<button
type="button"
className={`${
choice == PronounChoice.friendsOnly
? "bg-slate-500"
: "bg-slate-600"
} hover:bg-slate-400 p-2`}
>
<People />
</button>
<button
type="button"
className={`${
choice == PronounChoice.avoid
? "bg-slate-500"
: "bg-slate-600"
} hover:bg-slate-400 p-2`}
>
<HandThumbsDown />
</button>
<button
type="button"
className="bg-red-600 dark:bg-red-700 hover:bg-red-700 hover:dark:bg-red-800 p-2"
>
<Trash3 />
</button>
</div>
</li>
);
})}
</ul>
</Card>
);
}

View file

@ -10,6 +10,7 @@ import FieldCard from "../lib/FieldCard";
import Card from "../lib/Card";
import { userState } from "../lib/store";
import { useRecoilValue } from "recoil";
import Loading from "../lib/Loading";
function UserPage() {
const params = useParams();
@ -21,15 +22,10 @@ function UserPage() {
fetchAPI<User>(`/users/${params.username}`).then((res) => {
setUser(res);
});
}, []);
}, [params.username]);
if (user == null) {
return (
<>
<ArrowClockwise />
<span>Loading...</span>
</>
);
return <Loading />;
}
return (
@ -57,7 +53,7 @@ function UserPage() {
{user.avatar_url && (
<img className="max-w-xs rounded-full" src={user.avatar_url} />
)}
<div className="flex flex-col lg:mx-auto">
<div className="flex flex-col">
{user.display_name && (
<h1 className="text-2xl font-bold">{user.display_name}</h1>
)}
@ -75,10 +71,11 @@ function UserPage() {
{user.bio}
</ReactMarkdown>
)}
{user.links.length !== 0 && user.fields.length === 0 && (
{user.links?.length && user.fields?.length && (
<div className="flex flex-col mx-auto lg:ml-auto">
{user.links.map((link) => (
{user.links.map((link, index) => (
<a
key={index}
href={link}
rel="nofollow noopener noreferrer me"
className="hover:underline text-sky-500 dark:text-sky-400"
@ -91,13 +88,14 @@ function UserPage() {
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 py-2">
{user.fields.map((field) => (
<FieldCard field={field}></FieldCard>
{user.fields?.map((field, index) => (
<FieldCard key={index} field={field}></FieldCard>
))}
{user.links.length !== 0 && (
{user.links?.length && (
<Card title="Links">
{user.links.map((link) => (
{user.links.map((link, index) => (
<a
key={index}
href={link}
rel="nofollow noopener noreferrer me"
className="hover:underline text-sky-500 dark:text-sky-400"

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useRecoilState } from "recoil";
import fetchAPI from "../../lib/fetch";
import Loading from "../../lib/Loading";
import { userState } from "../../lib/store";
import { MeUser } from "../../lib/types";
@ -12,6 +13,7 @@ interface CallbackResponse {
discord?: string;
ticket?: string;
require_invite?: boolean;
}
export default function Discord() {
@ -68,7 +70,7 @@ export default function Discord() {
}
if (user || state.isLoading) {
return <>Loading...</>;
return <Loading />;
}
return <>wow such login</>;

View file

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useRecoilValue } from "recoil";
import fetchAPI from "../../lib/fetch";
import Loading from "../../lib/Loading";
import { userState } from "../../lib/store";
interface URLsResponse {
@ -38,7 +39,7 @@ export default function Login() {
}, []);
if (state.loading) {
return <>Loading...</>;
return <Loading />;
} else if (state.error) {
return <>Error: {`${state.error}`}</>;
}