From bb96f631fe9656eef49c858906dfbbcaf2273a67 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 7 Jul 2022 11:23:16 +0300 Subject: [PATCH] Edit managers frontend --- app/components/Combobox.tsx | 37 +++++++---- app/permissions.ts | 17 +++-- app/routes/badges/$id.tsx | 9 ++- app/routes/badges/$id/edit.tsx | 113 +++++++++++++++++++++++++++++++++ app/styles/badges.css | 24 +++++++ app/styles/common.css | 4 ++ 6 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 app/routes/badges/$id/edit.tsx diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index fdfbd313b..44d43c908 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -14,6 +14,7 @@ interface ComboboxProps { options: ComboboxOption[]; inputName: string; placeholder: string; + className?: string; isLoading?: boolean; onChange?: (selectedOption?: ComboboxOption) => void; } @@ -23,6 +24,7 @@ export function Combobox>({ inputName, placeholder, onChange, + className, isLoading = false, }: ComboboxProps) { const [selectedOption, setSelectedOption] = @@ -56,7 +58,7 @@ export function Combobox>({ setQuery(event.target.value)} placeholder={isLoading ? "Loading..." : placeholder} - className="combobox-input" + className={clsx("combobox-input", className)} displayValue={(option) => (option as Unpacked)?.label ?? "" } @@ -96,10 +98,12 @@ export function Combobox>({ export function UserCombobox({ inputName, onChange, + userIdsToOmit, + className, }: Pick< ComboboxProps>, - "inputName" | "onChange" ->) { + "inputName" | "onChange" | "className" +> & { userIdsToOmit?: Set }) { const fetcher = useFetcher(); React.useEffect(() => { @@ -108,22 +112,29 @@ export function UserCombobox({ const isLoading = fetcher.type !== "done"; + const options = () => { + if (isLoading) return []; + + const data = userIdsToOmit + ? fetcher.data.users.filter((user) => !userIdsToOmit.has(user.id)) + : fetcher.data.users; + + return data.map((u) => ({ + label: u.discordFullName, + value: String(u.id), + discordId: u.discordId, + plusTier: u.plusTier, + })); + }; + return ( ({ - label: u.discordFullName, - value: String(u.id), - discordId: u.discordId, - plusTier: u.plusTier, - })) - } + options={options()} placeholder="Sendou#0043" isLoading={isLoading} onChange={onChange} + className={className} /> ); } diff --git a/app/permissions.ts b/app/permissions.ts index 79377440e..1b8ba84c3 100644 --- a/app/permissions.ts +++ b/app/permissions.ts @@ -8,17 +8,18 @@ import type { ManagersByBadgeId } from "./db/models/badges.server"; // TODO: 1) move "root checkers" to one file and utils to one file 2) make utils const for more terseness -export function canPerformAdminActions(user?: Pick) { +type IsAdminUser = Pick; +function isAdmin(user?: IsAdminUser) { + return user?.discordId === ADMIN_DISCORD_ID; +} + +export function canPerformAdminActions(user?: IsAdminUser) { if (["development", "test"].includes(process.env.NODE_ENV)) return true; return isAdmin(user); } -function isAdmin(user?: Pick) { - return user?.discordId === ADMIN_DISCORD_ID; -} - -function adminOverride(user?: Pick) { +function adminOverride(user?: IsAdminUser) { if (isAdmin(user)) { return () => true; } @@ -248,3 +249,7 @@ function isBadgeManager({ if (!user) return false; return managers.some((manager) => manager.id === user.id); } + +export function canEditBadgeManagers(user?: IsAdminUser) { + return isAdmin(user); +} diff --git a/app/routes/badges/$id.tsx b/app/routes/badges/$id.tsx index 1104c486a..5f8ebc752 100644 --- a/app/routes/badges/$id.tsx +++ b/app/routes/badges/$id.tsx @@ -1,6 +1,6 @@ import { json } from "@remix-run/node"; import type { LoaderFunction } from "@remix-run/node"; -import { useLoaderData, useMatches, useParams } from "@remix-run/react"; +import { Outlet, useLoaderData, useMatches, useParams } from "@remix-run/react"; import clsx from "clsx"; import { Badge } from "~/components/Badge"; import { LinkButton } from "~/components/Button"; @@ -17,6 +17,10 @@ import { discordFullName } from "~/utils/strings"; import { BADGES_PAGE } from "~/utils/urls"; import type { BadgesLoaderData } from "../badges"; +export interface BadgeDetailsContext { + badgeName: string; +} + export interface BadgeDetailsLoaderData { owners: OwnersByBadgeId; managers: ManagersByBadgeId; @@ -44,8 +48,11 @@ export default function BadgeDetailsPage() { const badge = badges.find((b) => b.id === Number(params["id"])); if (!badge) return ; + const context: BadgeDetailsContext = { badgeName: badge.displayName }; + return (
+
{badgeExplanationText(badge)}
{canEditBadgeOwners({ user, managers: data.managers }) ? ( diff --git a/app/routes/badges/$id/edit.tsx b/app/routes/badges/$id/edit.tsx new file mode 100644 index 000000000..fe69f2bee --- /dev/null +++ b/app/routes/badges/$id/edit.tsx @@ -0,0 +1,113 @@ +import * as React from "react"; +import { useMatches, useOutletContext } from "@remix-run/react"; +import { Button, LinkButton } from "~/components/Button"; +import { Dialog } from "~/components/Dialog"; +import { atOrError } from "~/utils/arrays"; +import type { BadgeDetailsContext, BadgeDetailsLoaderData } from "../$id"; +import { discordFullName } from "~/utils/strings"; +import { UserCombobox } from "~/components/Combobox"; +import { TrashIcon } from "~/components/icons/Trash"; + +// xxx: on SSR modal flickers first shown at top +export default function EditBadgePage() { + const matches = useMatches(); + const data = atOrError(matches, -2).data as BadgeDetailsLoaderData; + const { badgeName } = useOutletContext(); + + return ( + +
+

+ Editing winners of {badgeName} +

+ + Cancel + +
+ + + +
+ ); +} + +function Managers({ data }: { data: BadgeDetailsLoaderData }) { + const [managers, setManagers] = React.useState( + data.managers.map((m) => ({ + id: m.id, + discordFullName: discordFullName(m), + })) + ); + + const amountOfChanges = managers + .filter((m) => !data.managers.some((om) => om.id === m.id)) + // maps to id to keep typescript happy + .map((m) => m.id) + // needed so we can also list amount of removed managers + .concat( + data.managers + .filter((om) => !managers.some((m) => m.id === om.id)) + .map((m) => m.id) + ).length; + + return ( +
+
+

Managers

+
    + {managers.map((manager) => ( +
  • + {manager.discordFullName} +
  • + ))} +
+ { + if (!user) return; + + setManagers([ + ...managers, + { discordFullName: user.label, id: Number(user.value) }, + ]); + }} + userIdsToOmit={new Set(managers.map((m) => m.id))} + /> +
+ +
+ ); +} + +function Owners({ data }: { data: BadgeDetailsLoaderData }) { + return ( +
+

Owners

+
+ +
+
+ ); +} diff --git a/app/styles/badges.css b/app/styles/badges.css index 1e2fa8e99..1a5f2c9ab 100644 --- a/app/styles/badges.css +++ b/app/styles/badges.css @@ -52,3 +52,27 @@ color: var(--theme-vibrant); font-size: var(--fonts-xs); } + +.badges-edit__users-list > li { + list-style: none; + display: flex; + justify-content: space-between; + align-items: center; +} + +.badges-edit__users-list { + padding: 0; + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + display: flex; + gap: var(--s-2); + flex-direction: column; +} + +.badges-edit__big-header { + font-size: var(--fonts-lg); +} + +.badges-edit__small-header { + font-size: var(--fonts-md); +} diff --git a/app/styles/common.css b/app/styles/common.css index 2bad910be..9752b1216 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -217,6 +217,10 @@ margin-inline-start: auto; } +.mx-auto { + margin: 0 auto; +} + .hidden { display: none; }