Team custom colors

This commit is contained in:
Kalle
2023-01-25 00:52:24 +02:00
parent d4f0849987
commit 4ed1cbc23f
11 changed files with 84 additions and 56 deletions

View File

@@ -19,7 +19,7 @@ type CustomColorsRecord = Partial<
export function CustomizedColorsInput({
initialColors,
}: {
initialColors?: Record<string, string>;
initialColors?: Record<string, string> | null;
}) {
const { t } = useTranslation();
const [colors, setColors] = React.useState<CustomColorsRecord>(

View File

@@ -37,8 +37,8 @@ export const action = async ({ request }: ActionArgs) => {
validate(validatedType);
validate(user.team);
const detailedTeam = findByIdentifier(user.team.customUrl);
validate(detailedTeam && isTeamOwner({ team: detailedTeam, user }));
const detailed = findByIdentifier(user.team.customUrl);
validate(detailed && isTeamOwner({ team: detailed.team, user }));
// TODO: graceful error handling when uploading many images
validate(countUnvalidatedImg(user.id) < MAX_UNVALIDATED_IMG_COUNT);
@@ -82,9 +82,9 @@ export const loader = async ({ request }: LoaderArgs) => {
throw redirect("/");
}
const detailedTeam = findByIdentifier(user.team.customUrl);
const detailed = findByIdentifier(user.team.customUrl);
if (!detailedTeam || !isTeamOwner({ team: detailedTeam, user })) {
if (!detailed || !isTeamOwner({ team: detailed.team, user })) {
throw redirect("/");
}

View File

@@ -7,7 +7,8 @@ const stm = sql.prepare(/*sql*/ `
"name" = @name,
"customUrl" = @customUrl,
"bio" = @bio,
"twitter" = @twitter
"twitter" = @twitter,
"css" = @css
where "id" = @id
returning *
`);
@@ -18,12 +19,14 @@ export function edit({
customUrl,
bio,
twitter,
}: Pick<Team, "id" | "name" | "customUrl" | "bio" | "twitter">) {
css,
}: Pick<Team, "id" | "name" | "customUrl" | "bio" | "twitter" | "css">) {
return stm.get({
id,
name,
customUrl,
bio,
twitter,
css,
}) as Team;
}

View File

@@ -10,6 +10,7 @@ const teamStm = sql.prepare(/*sql*/ `
"t"."twitter",
"t"."bio",
"t"."customUrl",
"t"."css",
"ia"."url" as "avatarSrc",
"ib"."url" as "bannerSrc",
json_group_array("User"."country") as "countries"
@@ -29,6 +30,7 @@ const membersStm = sql.prepare(/*sql*/ `
"User"."discordAvatar",
"User"."discordId",
"User"."discordDiscriminator",
"User"."patronTier",
"TeamMember"."role",
"TeamMember"."isOwner",
json_group_array("UserWeapon"."weaponSplId") as "weapons"
@@ -40,7 +42,7 @@ const membersStm = sql.prepare(/*sql*/ `
`);
type TeamRow =
| (Pick<Team, "id" | "name" | "twitter" | "bio" | "customUrl"> & {
| (Pick<Team, "id" | "name" | "twitter" | "bio" | "customUrl" | "css"> & {
avatarSrc: string;
bannerSrc: string;
countries: string;
@@ -55,11 +57,14 @@ type MemberRows = Array<
| "discordAvatar"
| "discordId"
| "discordDiscriminator"
| "patronTier"
> &
Pick<TeamMember, "role" | "isOwner"> & { weapons: string }
>;
export function findByIdentifier(customUrl: string): DetailedTeam | null {
export function findByIdentifier(
customUrl: string
): { team: DetailedTeam; css: Record<string, string> | null } | null {
const team = teamStm.get({ customUrl: customUrl.toLowerCase() }) as TeamRow;
if (!team) return null;
@@ -67,36 +72,40 @@ export function findByIdentifier(customUrl: string): DetailedTeam | null {
const members = membersStm.all({ teamId: team.id }) as MemberRows;
return {
id: team.id,
name: team.name,
customUrl: team.customUrl,
twitter: team.twitter ?? undefined,
bio: team.bio ?? undefined,
avatarSrc: team.avatarSrc,
bannerSrc: team.bannerSrc,
countries: removeDuplicates(JSON.parse(team.countries).filter(Boolean)),
members: members.map((member) => ({
id: member.id,
discordAvatar: member.discordAvatar,
discordId: member.discordId,
discordName: member.discordName,
discordDiscriminator: member.discordDiscriminator,
role: member.role ?? undefined,
isOwner: Boolean(member.isOwner),
weapons: JSON.parse(member.weapons).filter(Boolean),
})),
// results: {
// count: 23,
// placements: [
// {
// count: 10,
// placement: 1,
// },
// {
// count: 5,
// placement: 2,
// },
// ],
// },
css: team.css ? (JSON.parse(team.css) as Record<string, string>) : null,
team: {
id: team.id,
name: team.name,
customUrl: team.customUrl,
twitter: team.twitter ?? undefined,
bio: team.bio ?? undefined,
avatarSrc: team.avatarSrc,
bannerSrc: team.bannerSrc,
countries: removeDuplicates(JSON.parse(team.countries).filter(Boolean)),
members: members.map((member) => ({
id: member.id,
discordAvatar: member.discordAvatar,
discordId: member.discordId,
discordName: member.discordName,
discordDiscriminator: member.discordDiscriminator,
patronTier: member.patronTier,
role: member.role ?? undefined,
isOwner: Boolean(member.isOwner),
weapons: JSON.parse(member.weapons).filter(Boolean),
})),
// results: {
// count: 23,
// placements: [
// {
// count: 10,
// placement: 1,
// },
// {
// count: 5,
// placement: 2,
// },
// ],
// },
},
};
}

View File

@@ -11,6 +11,7 @@ import {
import { Form, Link, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { Button } from "~/components/Button";
import { CustomizedColorsInput } from "~/components/CustomizedColorsInput";
import { FormErrors } from "~/components/FormErrors";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
@@ -39,7 +40,7 @@ import { edit } from "../queries/edit.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { TEAM } from "../team-constants";
import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import { isTeamOwner } from "../team-utils";
import { canAddCustomizedColors, isTeamOwner } from "../team-utils";
import styles from "../team.css";
export const links: LinksFunction = () => {
@@ -84,7 +85,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
validate(isTeamOwner({ team, user }));
@@ -101,10 +102,10 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "EDIT": {
const newCustomUrl = mySlugify(data.name);
const existingTeam = findByIdentifier(newCustomUrl);
const existing = findByIdentifier(newCustomUrl);
// can't take someone else's custom url
if (existingTeam && existingTeam.id !== team.id) {
if (existing && existing.team.id !== team.id) {
return {
errors: ["forms.errors.duplicateName"],
};
@@ -128,18 +129,18 @@ export const loader = async ({ request, params }: LoaderArgs) => {
const user = await requireUserId(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const { team, css } = notFoundIfFalsy(findByIdentifier(customUrl));
if (!isTeamOwner({ team, user })) {
throw redirect(teamPage(customUrl));
}
return { team };
return { team, css };
};
export default function EditTeamPage() {
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
const { team, css } = useLoaderData<typeof loader>();
return (
<Main className="half-width">
@@ -157,6 +158,9 @@ export default function EditTeamPage() {
</FormWithConfirm>
<Form method="post" className="stack md items-start">
<ImageUploadLinks />
{canAddCustomizedColors(team) ? (
<CustomizedColorsInput initialColors={css} />
) : null}
<NameInput />
<TwitterInput />
<BioTextarea />

View File

@@ -32,7 +32,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
const realInviteCode = inviteCodeById(team.id)!;
@@ -59,7 +59,7 @@ export const loader = async ({ request, params }: LoaderArgs) => {
const user = await requireUser(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
const realInviteCode = inviteCodeById(team.id)!;

View File

@@ -63,7 +63,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
validate(isTeamOwner({ team, user }));
const data = await parseRequestFormData({
@@ -132,7 +132,7 @@ export const loader = async ({ request, params }: LoaderArgs) => {
const user = await requireUserId(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
if (!isTeamOwner({ team, user })) {
throw redirect(teamPage(customUrl));

View File

@@ -41,7 +41,11 @@ import { findByIdentifier } from "../queries/findByIdentifier.server";
import { leaveTeam } from "../queries/leaveTeam.server";
import { teamParamsSchema } from "../team-schemas.server";
import type { DetailedTeamMember, TeamResultPeek } from "../team-types";
import { isTeamMember, isTeamOwner } from "../team-utils";
import {
canAddCustomizedColors,
isTeamMember,
isTeamOwner,
} from "../team-utils";
import styles from "../team.css";
export const meta: MetaFunction = ({
@@ -65,7 +69,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
validate(isTeamMember({ user, team }) && !isTeamOwner({ user, team }));
@@ -99,9 +103,9 @@ export const handle: SendouRouteHandle = {
export const loader = ({ params }: LoaderArgs) => {
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(findByIdentifier(customUrl));
const { team, css } = notFoundIfFalsy(findByIdentifier(customUrl));
return { team };
return { team, css: canAddCustomizedColors(team) ? css : null };
};
export default function TeamPage() {

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { falsyToNull, id } from "~/utils/zod";
import { falsyToNull, id, jsonParseable } from "~/utils/zod";
import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
export const teamParamsSchema = z.object({ customUrl: z.string() });
@@ -23,6 +23,7 @@ export const editTeamSchema = z.union([
falsyToNull,
z.string().max(TEAM.TWITTER_MAX_LENGTH).nullable()
),
css: z.preprocess(falsyToNull, z.string().refine(jsonParseable).nullable()),
}),
]);

View File

@@ -23,6 +23,7 @@ export interface DetailedTeamMember {
isOwner: boolean;
weapons: MainWeaponId[];
role?: MemberRole;
patronTier: number | null;
}
export interface TeamResultPeek {

View File

@@ -28,3 +28,9 @@ export function isTeamMember({
export function isTeamFull(team: DetailedTeam) {
return team.members.length >= TEAM.MAX_MEMBER_COUNT;
}
export function canAddCustomizedColors(team: DetailedTeam) {
return team.members.some(
(member) => member.patronTier && member.patronTier >= 2
);
}