mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-02 22:26:57 -05:00
Edit managers frontend
This commit is contained in:
parent
67c8b2c449
commit
bb96f631fe
|
|
@ -14,6 +14,7 @@ interface ComboboxProps<T> {
|
|||
options: ComboboxOption<T>[];
|
||||
inputName: string;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
onChange?: (selectedOption?: ComboboxOption<T>) => void;
|
||||
}
|
||||
|
|
@ -23,6 +24,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
|
|||
inputName,
|
||||
placeholder,
|
||||
onChange,
|
||||
className,
|
||||
isLoading = false,
|
||||
}: ComboboxProps<T>) {
|
||||
const [selectedOption, setSelectedOption] =
|
||||
|
|
@ -56,7 +58,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
|
|||
<HeadlessCombobox.Input
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={isLoading ? "Loading..." : placeholder}
|
||||
className="combobox-input"
|
||||
className={clsx("combobox-input", className)}
|
||||
displayValue={(option) =>
|
||||
(option as Unpacked<typeof options>)?.label ?? ""
|
||||
}
|
||||
|
|
@ -96,10 +98,12 @@ export function Combobox<T extends Record<string, string | null | number>>({
|
|||
export function UserCombobox({
|
||||
inputName,
|
||||
onChange,
|
||||
userIdsToOmit,
|
||||
className,
|
||||
}: Pick<
|
||||
ComboboxProps<Pick<UserWithPlusTier, "discordId" | "plusTier">>,
|
||||
"inputName" | "onChange"
|
||||
>) {
|
||||
"inputName" | "onChange" | "className"
|
||||
> & { userIdsToOmit?: Set<number> }) {
|
||||
const fetcher = useFetcher<UsersLoaderData>();
|
||||
|
||||
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 (
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={
|
||||
isLoading
|
||||
? []
|
||||
: fetcher.data.users.map((u) => ({
|
||||
label: u.discordFullName,
|
||||
value: String(u.id),
|
||||
discordId: u.discordId,
|
||||
plusTier: u.plusTier,
|
||||
}))
|
||||
}
|
||||
options={options()}
|
||||
placeholder="Sendou#0043"
|
||||
isLoading={isLoading}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<User, "discordId">) {
|
||||
type IsAdminUser = Pick<User, "discordId">;
|
||||
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<User, "discordId">) {
|
||||
return user?.discordId === ADMIN_DISCORD_ID;
|
||||
}
|
||||
|
||||
function adminOverride(user?: Pick<User, "discordId">) {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <Redirect to={BADGES_PAGE} />;
|
||||
|
||||
const context: BadgeDetailsContext = { badgeName: badge.displayName };
|
||||
|
||||
return (
|
||||
<div className="stack md items-center">
|
||||
<Outlet context={context} />
|
||||
<Badge badge={badge} isAnimated size={200} />
|
||||
<div className="badges__explanation">{badgeExplanationText(badge)}</div>
|
||||
{canEditBadgeOwners({ user, managers: data.managers }) ? (
|
||||
|
|
|
|||
113
app/routes/badges/$id/edit.tsx
Normal file
113
app/routes/badges/$id/edit.tsx
Normal file
|
|
@ -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<BadgeDetailsContext>();
|
||||
|
||||
return (
|
||||
<Dialog isOpen className="stack md">
|
||||
<div>
|
||||
<h2 className="badges-edit__big-header">
|
||||
Editing winners of {badgeName}
|
||||
</h2>
|
||||
<LinkButton
|
||||
to={atOrError(matches, -2).pathname}
|
||||
variant="minimal-destructive"
|
||||
tiny
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<Managers data={data} />
|
||||
<Owners data={data} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="stack md">
|
||||
<div className="stack sm">
|
||||
<h3 className="badges-edit__small-header">Managers</h3>
|
||||
<ul className="badges-edit__users-list">
|
||||
{managers.map((manager) => (
|
||||
<li key={manager.id}>
|
||||
{manager.discordFullName}
|
||||
<Button
|
||||
icon={<TrashIcon />}
|
||||
variant="minimal-destructive"
|
||||
aria-label="Delete badge manager"
|
||||
onClick={() =>
|
||||
setManagers(managers.filter((m) => m.id !== manager.id))
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<UserCombobox
|
||||
className="mx-auto"
|
||||
inputName="new-manager"
|
||||
onChange={(user) => {
|
||||
if (!user) return;
|
||||
|
||||
setManagers([
|
||||
...managers,
|
||||
{ discordFullName: user.label, id: Number(user.value) },
|
||||
]);
|
||||
}}
|
||||
userIdsToOmit={new Set(managers.map((m) => m.id))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
tiny
|
||||
className="w-full"
|
||||
disabled={amountOfChanges === 0}
|
||||
>
|
||||
{amountOfChanges > 0 ? `Submit ${amountOfChanges} changes` : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Owners({ data }: { data: BadgeDetailsLoaderData }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="badges-edit__small-header">Owners</h3>
|
||||
<div className="stack vertical md">
|
||||
<Button type="submit" tiny>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,6 +217,10 @@
|
|||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user