mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 06:08:13 -05:00
Team custom colors
This commit is contained in:
@@ -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>(
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface DetailedTeamMember {
|
||||
isOwner: boolean;
|
||||
weapons: MainWeaponId[];
|
||||
role?: MemberRole;
|
||||
patronTier: number | null;
|
||||
}
|
||||
|
||||
export interface TeamResultPeek {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user