feat(frontend): start edit page
This commit is contained in:
parent
2ee1087eec
commit
8b31519952
15 changed files with 556 additions and 44 deletions
|
@ -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>
|
||||
|
|
14
frontend/src/lib/BlueLink.tsx
Normal file
14
frontend/src/lib/BlueLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 && (
|
||||
|
|
10
frontend/src/lib/Loading.tsx
Normal file
10
frontend/src/lib/Loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
|
|
19
frontend/src/lib/TextInput.tsx
Normal file
19
frontend/src/lib/TextInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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</>;
|
||||
|
|
|
@ -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}`}</>;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue