From 7a11798437deefd3dd395bece8acd8903b7478b6 Mon Sep 17 00:00:00 2001 From: NotNite Date: Mon, 27 Nov 2023 18:51:39 -0500 Subject: [PATCH] Crews! --- package-lock.json | 21 +++- package.json | 1 + src/App.tsx | 4 + src/api/crew.ts | 124 +++++++++++++++++++ src/api/types.ts | 27 +++++ src/api/util.ts | 36 ++++++ src/components/Avatar.tsx | 17 +++ src/components/CurrentAccount.tsx | 9 +- src/components/FakeTMP.tsx | 102 ++++++++++++++++ src/components/HiddenCode.tsx | 38 ++++++ src/components/Important.tsx | 13 ++ src/components/TMPInput.tsx | 28 +++++ src/index.css | 94 +++++++++++++- src/main.tsx | 35 ++++++ src/routes/404.tsx | 18 +++ src/routes/Redirect.tsx | 2 - src/routes/Settings.tsx | 38 ++---- src/routes/crews/Create.tsx | 76 ++++++++++++ src/routes/crews/Crew.tsx | 195 ++++++++++++++++++++++++++++++ src/routes/crews/CrewSettings.tsx | 3 + src/routes/crews/Crews.tsx | 108 +++++++++++++++++ src/routes/loaders.ts | 26 ++++ src/util.ts | 14 +++ 23 files changed, 984 insertions(+), 45 deletions(-) create mode 100644 src/api/crew.ts create mode 100644 src/api/util.ts create mode 100644 src/components/Avatar.tsx create mode 100644 src/components/FakeTMP.tsx create mode 100644 src/components/HiddenCode.tsx create mode 100644 src/components/Important.tsx create mode 100644 src/components/TMPInput.tsx create mode 100644 src/routes/404.tsx create mode 100644 src/routes/crews/Create.tsx create mode 100644 src/routes/crews/Crew.tsx create mode 100644 src/routes/crews/CrewSettings.tsx create mode 100644 src/routes/crews/Crews.tsx create mode 100644 src/routes/loaders.ts create mode 100644 src/util.ts diff --git a/package-lock.json b/package-lock.json index 3a3591e..86bb5a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "react-dom": "^18.2.0", "react-feather": "^2.0.10", "react-router-dom": "^6.18.0", + "react-router-typesafe": "^1.4.4", "zustand": "^4.4.6" }, "devDependencies": { @@ -2628,6 +2629,16 @@ "react-dom": ">=16.8" } }, + "node_modules/react-router-typesafe": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/react-router-typesafe/-/react-router-typesafe-1.4.4.tgz", + "integrity": "sha512-XYYwRabe7UoEBZ229Jeg2U4dfU3XvUToX9FTUkJjzDSN+2B4fAIeEKsr961PsA/xJ5bSCd7LwDf0A1deniAdRw==", + "peerDependencies": { + "react": ">= 17", + "react-router-dom": ">= 6.4.0", + "typescript": ">= 4.9" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2884,7 +2895,6 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4827,6 +4837,12 @@ "react-router": "6.18.0" } }, + "react-router-typesafe": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/react-router-typesafe/-/react-router-typesafe-1.4.4.tgz", + "integrity": "sha512-XYYwRabe7UoEBZ229Jeg2U4dfU3XvUToX9FTUkJjzDSN+2B4fAIeEKsr961PsA/xJ5bSCd7LwDf0A1deniAdRw==", + "requires": {} + }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4997,8 +5013,7 @@ "typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", - "dev": true + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==" }, "update-browserslist-db": { "version": "1.0.13", diff --git a/package.json b/package.json index cd069fe..c6f35a8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "react-dom": "^18.2.0", "react-feather": "^2.0.10", "react-router-dom": "^6.18.0", + "react-router-typesafe": "^1.4.4", "zustand": "^4.4.6" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 5f53ff8..7ed55da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,10 @@ export default function Root() { diff --git a/src/api/crew.ts b/src/api/crew.ts new file mode 100644 index 0000000..c151fa2 --- /dev/null +++ b/src/api/crew.ts @@ -0,0 +1,124 @@ +import { useAuthStore } from "../stores"; +import { CrewResponse, Result, SimpleCrewResponse } from "./types"; +import { tryFetch } from "./util"; + +export async function getCrews() { + const token = useAuthStore.getState().key; + if (token == null) return null; + + const req = await fetch( + `${import.meta.env.VITE_SLOP_CREW_SERVER}api/crew/crews`, + { + headers: { + Authorization: token + } + } + ); + + if (!req.ok) return null; + const res: SimpleCrewResponse[] = await req.json(); + return res; +} + +export async function getCrew(id: string) { + const token = useAuthStore.getState().key; + if (token == null) return null; + + const req = await fetch( + `${import.meta.env.VITE_SLOP_CREW_SERVER}api/crew/${id}`, + { + headers: { + Authorization: token + } + } + ); + + if (!req.ok) return null; + const res: CrewResponse = await req.json(); + return res; +} + +export async function create( + name: string, + tag: string +): Promise> { + const token = useAuthStore.getState().key; + if (token == null) { + return { + ok: false, + error: "You must be logged in to create a crew" + }; + } + + const req = await tryFetch( + `${import.meta.env.VITE_SLOP_CREW_SERVER}api/crew/create`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token + }, + body: JSON.stringify({ + name, + tag + }) + } + ); + + if (!req.ok) return req; + const res: SimpleCrewResponse = await req.value.json(); + return { ok: true, value: res }; +} + +export async function promote( + crew: string, + user: string +): Promise> { + const token = useAuthStore.getState().key; + if (token == null) { + return { + ok: false, + error: "You must be logged in to promote a user" + }; + } + + const req = await tryFetch( + `${ + import.meta.env.VITE_SLOP_CREW_SERVER + }api/crew/${crew}/promote?id=${user}`, + { + method: "POST", + headers: { + Authorization: token + } + } + ); + if (!req.ok) return req; + return { ok: true, value: null }; +} + +export async function join( + code: string +): Promise> { + const token = useAuthStore.getState().key; + if (token == null) { + return { + ok: false, + error: "You must be logged in to join a crew" + }; + } + + const req = await tryFetch( + `${import.meta.env.VITE_SLOP_CREW_SERVER}api/crew/join?code=${code}`, + { + method: "POST", + headers: { + Authorization: token + } + } + ); + + if (!req.ok) return req; + const res: SimpleCrewResponse = await req.value.json(); + return { ok: true, value: res }; +} diff --git a/src/api/types.ts b/src/api/types.ts index 0897db4..d0cf548 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -7,3 +7,30 @@ export type MeResponse = { export type AuthResponse = MeResponse & { key: string; }; + +export type SimpleCrewResponse = { + id: string; + name: string; + tag: string; +}; + +export type CrewResponse = SimpleCrewResponse & { + members: CrewMember[]; +}; + +export type CrewMember = { + id: string; + username: string; + owner: boolean; + avatar: string | null; +}; + +export type Result = + | { + ok: true; + value: T; + } + | { + ok: false; + error: E; + }; diff --git a/src/api/util.ts b/src/api/util.ts new file mode 100644 index 0000000..1e0e57b --- /dev/null +++ b/src/api/util.ts @@ -0,0 +1,36 @@ +import { Result } from "./types"; + +export async function tryFetch( + input: string, + info: RequestInit +): Promise> { + let req; + try { + req = await fetch(input, info); + } catch (e) { + console.error(e); + + if (e instanceof Error) { + return { ok: false, error: e.message }; + } else { + return { ok: false, error: "Unknown error" }; + } + } + + if (!req.ok) { + const text = await req.text(); + if (text == "") { + return { + ok: false, + error: `Unknown error: ${req.status} ${req.statusText}` + }; + } else { + return { ok: false, error: text }; + } + } + + return { + ok: true, + value: req + }; +} diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx new file mode 100644 index 0000000..f478e38 --- /dev/null +++ b/src/components/Avatar.tsx @@ -0,0 +1,17 @@ +type HasAvatar = { + id: string; + avatar: string | null; + username: string; +}; + +export default function Avatar({ person }: { person: HasAvatar }) { + if (person.avatar == null) return <>; + + return ( + {person.username} + ); +} diff --git a/src/components/CurrentAccount.tsx b/src/components/CurrentAccount.tsx index 6056012..d386403 100644 --- a/src/components/CurrentAccount.tsx +++ b/src/components/CurrentAccount.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useAuthStore } from "../stores"; import { getMe } from "../api/auth"; import { Link } from "react-router-dom"; +import Avatar from "./Avatar"; export default function CurrentAccount() { const token = useAuthStore((state) => state.key); @@ -22,13 +23,7 @@ export default function CurrentAccount() {
  • - {cachedMe.avatar != null && ( - {cachedMe.username} - )} - + {cachedMe.username} diff --git a/src/components/FakeTMP.tsx b/src/components/FakeTMP.tsx new file mode 100644 index 0000000..8cd5784 --- /dev/null +++ b/src/components/FakeTMP.tsx @@ -0,0 +1,102 @@ +// Absolutely ~~abhorrent~~ wonderful regex made by Sylvie +const colorRegex = + /(?:<(?:color=)?(?:#?(?[0-9a-fA-F]{6}|[0-9a-fA-F]{3}|[0-9a-fA-F]{8})|(?\w+))>)/m; +const spriteRegex = //m; + +// https://docs.unity3d.com/Packages/com.unity.ugui@1.0/manual/StyledText.html#supported-colors +const colorNames: Record = { + aqua: "#00ffffff", + black: "#000000ff", + blue: "#0000ffff", + brown: "#a52a2aff", + cyan: "#00ffffff", + darkblue: "#0000a0ff", + fuchsia: "#ff00ffff", + green: "#008000ff", + grey: "#808080ff", + lightblue: "#add8e6ff", + lime: "#00ff00ff", + magenta: "#ff00ffff", + maroon: "#800000ff", + navy: "#000080ff", + olive: "#808000ff", + orange: "#ffa500ff", + purple: "#800080ff", + red: "#ff0000ff", + silver: "#c0c0c0ff", + teal: "#008080ff", + white: "#ffffffff", + yellow: "#ffff00ff" +}; + +function parse(temp: string): React.ReactNode[] { + const spans: React.ReactNode[] = []; + let text = temp; + let currentColor: string | null = null; + + function add(str: string) { + if (currentColor == null) { + spans.push( + + {str} + + ); + } else { + spans.push( + + {str} + + ); + } + } + + while (text.length > 0) { + const colorMatch = colorRegex.exec(text); + if (colorMatch != null) { + // if there's text before this, add it to the spans first + if (colorMatch.index > 0) { + const chunk = text.slice(0, colorMatch.index); + add(chunk); + text = text.slice(colorMatch.index); + continue; + } + + // if there's a color, add it to the spans + if (colorMatch.groups?.hex != null) { + const hex = colorMatch.groups.hex; + currentColor = `#${hex}`; + } + + if (colorMatch.groups?.name != null) { + currentColor = colorNames[colorMatch.groups.name]; + } + + text = text.slice(colorMatch[0].length); + continue; + } + + const spriteMatch = spriteRegex.exec(text); + if (spriteMatch != null) { + // Just strip sprite matches + if (spriteMatch.index > 0) { + const chunk = text.slice(0, spriteMatch.index); + add(chunk); + text = text.slice(spriteMatch.index); + continue; + } + + text = text.slice(spriteMatch[0].length); + continue; + } + + add(text); + text = ""; + } + + return spans; +} + +export default function FakeTMP({ text }: { text: string }) { + const spans = parse(text); + return {spans}; +} diff --git a/src/components/HiddenCode.tsx b/src/components/HiddenCode.tsx new file mode 100644 index 0000000..2e8fa6d --- /dev/null +++ b/src/components/HiddenCode.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Copy, Eye, EyeOff } from "react-feather"; + +// exact length of a uuidv4 +const DEFAULT_PLACEHOLDER = "************************************"; + +export default function HiddenCode({ + code, + placeholder +}: { + code: string; + placeholder?: string; +}) { + const [hidden, setHidden] = React.useState(true); + const [copyClicked, setCopyClicked] = React.useState(false); + const ButtonElem = hidden ? EyeOff : Eye; + + const placeholderToUse = placeholder ?? DEFAULT_PLACEHOLDER; + + return ( +
    + setHidden(!hidden)} /> + + { + navigator.clipboard.writeText(code); + setCopyClicked(true); + + setTimeout(() => { + setCopyClicked(false); + }, 500); + }} + /> + {hidden ? placeholderToUse : code} +
    + ); +} diff --git a/src/components/Important.tsx b/src/components/Important.tsx new file mode 100644 index 0000000..8b9f992 --- /dev/null +++ b/src/components/Important.tsx @@ -0,0 +1,13 @@ +export default function Important({ + type, + message +}: { + type: "danger" | "warning"; + message: string; +}) { + return ( +
    +

    {message}

    +
    + ); +} diff --git a/src/components/TMPInput.tsx b/src/components/TMPInput.tsx new file mode 100644 index 0000000..6f66fb1 --- /dev/null +++ b/src/components/TMPInput.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import FakeTMP from "./FakeTMP"; + +type InputProps = React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement +>; + +const TMPInput = React.forwardRef( + (props, ref) => { + const [text, setText] = React.useState(""); + + return ( + <> + + + setText(e.target.value)} + {...props} + /> + + ); + } +); + +export default TMPInput; diff --git a/src/index.css b/src/index.css index 5ff49b9..f89dd48 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,18 @@ :root { - --primary: #43a047; + --primary: #43a047 !important; + --primary-hover: #388e3c !important; + --primary-focus: rgba(67, 160, 71, 0.125) !important; + --primary-inverse: #fff !important; + + --warning: #ffb300; + --warning-hover: #ffa000; + --warning-focus: rgba(255, 179, 0, 0.125); + --warning-inverse: rgba(0, 0, 0, 0.75); + + --danger: #e53935; + --danger-hover: #d32f2f; + --danger-focus: rgba(229, 57, 53, 0.125); + --danger-inverse: #fff; } .currentAccount { @@ -8,10 +21,15 @@ height: 3rem; } -.currentAccount img { +.avatar { + width: 5rem; + height: 5rem; + border-radius: 25%; +} + +.currentAccount .avatar { width: 2rem; height: 2rem; - border-radius: 25%; } .authKey { @@ -23,7 +41,7 @@ } /* Make text not double clickable */ -.authKey code { +.hiddenCode code { user-select: none; } @@ -40,3 +58,71 @@ color: var(--color); } } + +.important { + padding: calc(var(--block-spacing-vertical) / 2) + var(--block-spacing-horizontal); + margin: calc(var(--block-spacing-vertical) / 2) 0; +} + +.important p { + color: var(--contrast); + margin: 0; +} + +.warning { + --background-color: var(--warning) !important; + --card-background-color: var(--warning) !important; + --border-color: var(--warning) !important; + --color: var(--warning-inverse) !important; +} + +.danger { + --background-color: var(--danger) !important; + --card-background-color: var(--danger) !important; + --border-color: var(--danger) !important; + --color: var(--danger-inverse) !important; +} + +.normalWidthButton { + width: initial; +} + +/* Shitty hack for tooltips on disabled buttons */ +.tooltipHack { + display: inline-block; + border: none !important; +} + +.tooltipHack button { + margin-bottom: 0 !important; +} + +.smolPadding h1, +.smolPadding h2, +.smolPadding h3, +.smolPadding h4, +.smolPadding h5, +.smolPadding h6 { + margin-bottom: calc(var(--typography-spacing-vertical) / 2); +} + +.buttonGallery button, +.buttonGallery a[role="button"] { + margin-right: 1rem; +} + +.crewAvatar .avatar { + margin-right: 1rem; +} + +.inviteCode { + display: flex; + flex-direction: row; + gap: 1rem; +} + +.inviteCode input { + width: 0; + flex-grow: 1; +} diff --git a/src/main.tsx b/src/main.tsx index 68238ad..8757d58 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,11 +6,20 @@ import "@picocss/pico/css/pico.min.css"; import Root from "./App.tsx"; import ErrorPage from "./routes/Error.tsx"; +import _404 from "./routes/404.tsx"; + import Index from "./routes/Index.tsx"; import Link from "./routes/Link.tsx"; import Redirect from "./routes/Redirect.tsx"; import Settings from "./routes/Settings.tsx"; +import Crews from "./routes/crews/Crews.tsx"; +import CrewsCreate from "./routes/crews/Create.tsx"; +import Crew from "./routes/crews/Crew.tsx"; +import CrewSettings from "./routes/crews/CrewSettings.tsx"; + +import { crewsLoader, crewLoader } from "./routes/loaders.ts"; + const router = createBrowserRouter([ { path: "/", @@ -21,6 +30,7 @@ const router = createBrowserRouter([ index: true, element: }, + { path: "link", element: @@ -32,6 +42,31 @@ const router = createBrowserRouter([ { path: "settings", element: + }, + + { + path: "crews", + loader: crewsLoader, + element: + }, + { + path: "crews/create", + element: + }, + { + path: "crews/:id", + loader: crewLoader, + element: + }, + { + path: "crews/:id/settings", + loader: crewLoader, + element: + }, + + { + path: "*", + element: <_404 /> } ] } diff --git a/src/routes/404.tsx b/src/routes/404.tsx new file mode 100644 index 0000000..6d3ce89 --- /dev/null +++ b/src/routes/404.tsx @@ -0,0 +1,18 @@ +import { Link } from "react-router-dom"; + +export default function _404() { + return ( + <> +
    +

    404

    +

    Not found

    +
    + +

    + Whatever you're looking for doesn't exist; maybe it did at one point. + Sorry about that. You can click here to go to the + home page. +

    + + ); +} diff --git a/src/routes/Redirect.tsx b/src/routes/Redirect.tsx index 0b9081a..daa3d8d 100644 --- a/src/routes/Redirect.tsx +++ b/src/routes/Redirect.tsx @@ -19,8 +19,6 @@ export default function Redirect() { const [result, setResult] = React.useState(null); React.useEffect(() => { - console.log("Redirect useEffect"); - async function run() { const query = new URLSearchParams(window.location.search); const code = query.get("code"); diff --git a/src/routes/Settings.tsx b/src/routes/Settings.tsx index 9068666..3ad871f 100644 --- a/src/routes/Settings.tsx +++ b/src/routes/Settings.tsx @@ -1,36 +1,16 @@ -import React from "react"; import { useAuthStore } from "../stores"; -import { Eye, EyeOff, Copy } from "react-feather"; +import { useNavigate } from "react-router-dom"; +import HiddenCode from "../components/HiddenCode"; -function AuthKey() { +export default function Settings() { const key = useAuthStore((state) => state.key); - const [hidden, setHidden] = React.useState(true); - const [copyClicked, setCopyClicked] = React.useState(false); - - const ButtonElem = hidden ? EyeOff : Eye; - - if (key == null) return

    Not logged in.

    ; - - return ( -
    - setHidden(!hidden)} />{" "} - { - navigator.clipboard.writeText(key); - setCopyClicked(true); - setTimeout(() => { - setCopyClicked(false); - }, 500); - }} - /> - {hidden ? "************************************" : key} -
    - ); -} + const navigate = useNavigate(); + if (key == null) { + navigate("/link"); + return <>; + } -export default function Settings() { return ( <>

    Settings

    @@ -44,7 +24,7 @@ export default function Settings() { will never ask for your auth token.

    - + ); diff --git a/src/routes/crews/Create.tsx b/src/routes/crews/Create.tsx new file mode 100644 index 0000000..7c5fec5 --- /dev/null +++ b/src/routes/crews/Create.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import TMPInput from "../../components/TMPInput"; +import { Link, useNavigate } from "react-router-dom"; +import { create } from "../../api/crew"; +import Important from "../../components/Important"; + +export default function Create() { + const name = React.createRef(); + const tag = React.createRef(); + const navigate = useNavigate(); + + const [swag, setSwag] = React.useState(false); + const [working, setWorking] = React.useState(false); + const [error, setError] = React.useState(null); + + return ( + <> +

    Create a crew

    + + {error != null && } + + + +

    + If you represent this crew, its tag will + appear above your nameplate. TextMeshPro color tags are supported. An + approximate preview of how it will look in game will appear as you type. +

    + + + + setSwag(e.target.checked)} + /> + + + {/* why do i need two lmao */} +
    +
    + + + + ); +} diff --git a/src/routes/crews/Crew.tsx b/src/routes/crews/Crew.tsx new file mode 100644 index 0000000..3661200 --- /dev/null +++ b/src/routes/crews/Crew.tsx @@ -0,0 +1,195 @@ +import { Await, useLoaderData } from "react-router-typesafe"; +import { crewLoader } from "../loaders"; +import React from "react"; +import { Link, useNavigate, useRevalidator } from "react-router-dom"; +import FakeTMP from "../../components/FakeTMP"; +import { CrewMember, CrewResponse } from "../../api/types"; +import { useAuthStore } from "../../stores"; +import Avatar from "../../components/Avatar"; +import { useRequiredAuth } from "../../util"; + +function MembersActions({ + me, + member +}: { + me?: CrewMember; + member: CrewMember; +}) { + const revalidator = useRevalidator(); + + if (me?.id == member.id) { + return ; + } + + return ( + + {member.owner ? ( + + ) : ( + + )} + + + + ); +} + +function MembersTable({ + me, + members +}: { + me?: CrewMember; + members: CrewMember[]; +}) { + const isOwner = me?.owner == true; + + return ( + + + + + {isOwner && } + + + + + {members.map((member) => { + return ( + + + + {isOwner && } + + ); + })} + +
    UserActions
    + + {member.owner ? ( + {member.username} + ) : ( + {member.username} + )} +
    + ); +} + +function LeaveCrewButton({ + crew, + me +}: { + crew: CrewResponse; + me?: CrewMember; +}) { + const owners = crew.members.filter((x) => x.owner); + + // This is implemented serverside as well + const canLeaveMemberClause = me != null && crew.members.length > 1; + const canLeaveOwnersClause = me != null && (owners.length > 1 || !me.owner); + const canLeave = canLeaveMemberClause && canLeaveOwnersClause; + + let tooltip = ""; + if (!canLeaveMemberClause) { + tooltip = "You're the only member of this crew."; + } + + if (!canLeaveOwnersClause) { + tooltip = "You're the only owner of this crew."; + } + + return ( +
    + +
    + ); +} + +function CrewInner({ crew }: { crew: CrewResponse }) { + const cachedMe = useAuthStore((state) => state.cachedMe); + const me = crew.members.find((x) => x.id === cachedMe?.id); + const members = crew.members.sort((a, b) => { + // owners go first + if (a.owner && !b.owner) return -1; + if (!a.owner && b.owner) return 1; + + // fallback to name + return a.username.localeCompare(b.username); + }); + + return ( + <> +
    +

    {crew.name}

    +

    + +

    +
    + +
    +

    Actions

    + + {me?.owner && ( + + Settings + + )} + + +
    + +
    +

    Members

    + +
    + + ); +} + +export default function Crew() { + const data = useLoaderData(); + const navigate = useNavigate(); + useRequiredAuth(); + + return ( + Loading this crew...} + > + Failed to load crew.} + > + {(crew) => { + if (crew == null) { + navigate("/404"); + return <>; + } + + return ; + }} + + + ); +} diff --git a/src/routes/crews/CrewSettings.tsx b/src/routes/crews/CrewSettings.tsx new file mode 100644 index 0000000..4960311 --- /dev/null +++ b/src/routes/crews/CrewSettings.tsx @@ -0,0 +1,3 @@ +export default function CrewSettings() { + return TODO; +} diff --git a/src/routes/crews/Crews.tsx b/src/routes/crews/Crews.tsx new file mode 100644 index 0000000..7f60079 --- /dev/null +++ b/src/routes/crews/Crews.tsx @@ -0,0 +1,108 @@ +import { Await, useLoaderData } from "react-router-typesafe"; +import { crewsLoader } from "../loaders"; +import React from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useRequiredAuth } from "../../util"; +import { join } from "../../api/crew"; +import Important from "../../components/Important"; + +function CrewInviteCode() { + const navigate = useNavigate(); + const inputRef = React.useRef(null); + const [working, setWorking] = React.useState(false); + const [error, setError] = React.useState(null); + + return ( + <> + {error != null && } + +
    + + +
    + + ); +} + +export default function Crews() { + const data = useLoaderData(); + useRequiredAuth(); + + return ( + <> +
    +

    Crews

    +

    Form up together with your friends

    +
    + +
    + + Loading your crews...} + > + Failed to load crews.} + > + {(crews) => ( + <> +
    + + Create a crew + + +
    +
    + + +
    + + + + + + + + + + + + {crews.map((crew) => ( + + + + + + ))} + +
    NameTagActions
    {crew.name}{crew.tag} + + View + +
    + + )} +
    +
    + + ); +} diff --git a/src/routes/loaders.ts b/src/routes/loaders.ts new file mode 100644 index 0000000..acc9731 --- /dev/null +++ b/src/routes/loaders.ts @@ -0,0 +1,26 @@ +import { defer } from "react-router-typesafe"; +import { getCrew, getCrews } from "../api/crew"; +import { CrewResponse, SimpleCrewResponse } from "../api/types"; +import { Params } from "react-router-dom"; + +export type CrewsLoaderData = { + crews: Promise; +}; + +export async function crewsLoader() { + return defer({ + crews: getCrews().then((res) => res ?? []) + }); +} + +export type CrewLoaderData = { + crew: Promise; +}; + +export async function crewLoader({ params }: { params: Params<"id"> }) { + if (params.id == null) throw new Response("Not Found", { status: 404 }); + + return defer({ + crew: getCrew(params.id) + }); +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..33ab76d --- /dev/null +++ b/src/util.ts @@ -0,0 +1,14 @@ +import { useNavigate } from "react-router-dom"; +import { useAuthStore } from "./stores"; +import React from "react"; + +export function useRequiredAuth() { + const cachedMe = useAuthStore((state) => state.cachedMe); + const navigate = useNavigate(); + + React.useEffect(() => { + if (cachedMe == null) navigate("/link"); + }, [cachedMe, navigate]); + + return cachedMe; +}