From ae3349ec2d0328e16998c2c797b6c01c1bfdb521 Mon Sep 17 00:00:00 2001 From: hfcRed Date: Wed, 4 Mar 2026 19:39:29 +0100 Subject: [PATCH] Add custom theme to teams --- app/features/team/TeamRepository.server.ts | 16 +++++++ .../team/actions/t.$customUrl.edit.server.ts | 17 +++++++ .../team/loaders/t.$customUrl.edit.server.ts | 8 +++- .../team/routes/t.$customUrl.edit.tsx | 48 ++++++++++++++++++- app/features/team/team-schemas.server.ts | 7 +++ app/features/team/team.module.css | 4 ++ app/root.tsx | 22 +++++---- locales/en/team.json | 1 + 8 files changed, 109 insertions(+), 14 deletions(-) diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index b8bd734dd..b662a7b0f 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -331,6 +331,22 @@ export async function update({ return team; } +export async function updateCustomTheme({ + id, + customTheme, +}: { + id: number; + customTheme: CustomTheme | null; +}) { + await db + .updateTable("AllTeam") + .set({ + customTheme: customTheme ? JSON.stringify(customTheme) : null, + }) + .where("id", "=", id) + .execute(); +} + export function switchMainTeam({ userId, teamId, diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts index 557469723..9df37ad1c 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.ts @@ -41,6 +41,23 @@ export const action: ActionFunction = async ({ request, params }) => { } switch (data._action) { + case "UPDATE_CUSTOM_THEME": { + errorToastIfFalsy( + canAddCustomizedColors(team), + "Team does not have custom theme access", + ); + + const customTheme = data.newValue + ? clampThemeToGamut(data.newValue) + : null; + + await TeamRepository.updateCustomTheme({ + id: team.id, + customTheme, + }); + + return { ok: true }; + } case "DELETE_TEAM": { await TeamRepository.del(team.id); throw redirect(TEAM_SEARCH_PAGE); diff --git a/app/features/team/loaders/t.$customUrl.edit.server.ts b/app/features/team/loaders/t.$customUrl.edit.server.ts index 1c578179c..829e9a6b3 100644 --- a/app/features/team/loaders/t.$customUrl.edit.server.ts +++ b/app/features/team/loaders/t.$customUrl.edit.server.ts @@ -5,7 +5,7 @@ import { notFoundIfFalsy } from "~/utils/remix.server"; import { teamPage } from "~/utils/urls"; import * as TeamRepository from "../TeamRepository.server"; import { teamParamsSchema } from "../team-schemas.server"; -import { isTeamManager } from "../team-utils"; +import { canAddCustomizedColors, isTeamManager } from "../team-utils"; export const loader = async ({ params }: LoaderFunctionArgs) => { const user = requireUser(); @@ -17,5 +17,9 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { throw redirect(teamPage(customUrl)); } - return { team, customTheme: team.customTheme }; + return { + team, + customTheme: canAddCustomizedColors(team) ? team.customTheme : null, + canAddCustomizedColors: canAddCustomizedColors(team), + }; }; diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index ca5af2b63..0b2d222fa 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -1,7 +1,9 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Form, Link, useLoaderData } from "react-router"; +import { Form, Link, useFetcher, useLoaderData } from "react-router"; +import { CustomThemeSelector } from "~/components/CustomThemeSelector"; +import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; import { FormErrors } from "~/components/FormErrors"; import { FormMessage } from "~/components/FormMessage"; @@ -12,6 +14,7 @@ import { Main, mainStyles } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { useUser } from "~/features/auth/core/user"; import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton"; +import type { ThemeInput } from "~/utils/oklch-gamut"; import { metaTags } from "~/utils/remix"; import { uploadImagePage } from "~/utils/urls"; import { action } from "../actions/t.$customUrl.edit.server"; @@ -31,7 +34,7 @@ export const meta: MetaFunction = (args) => { export default function EditTeamPage() { const { t } = useTranslation(["common", "team"]); const user = useUser(); - const { team } = useLoaderData(); + const { team, canAddCustomizedColors } = useLoaderData(); return (
@@ -67,6 +70,14 @@ export default function EditTeamPage() { + {canAddCustomizedColors ? ( + <> + + {t("team:forms.customTheme.header")} + + + + ) : null}
); @@ -235,3 +246,36 @@ function BioTextarea() { ); } + +function TeamCustomThemeSelector() { + const { customTheme, canAddCustomizedColors } = + useLoaderData(); + const fetcher = useFetcher(); + + const handleSave = (themeInput: ThemeInput) => { + fetcher.submit( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: themeInput, + } as unknown as Parameters[0], + { method: "post", encType: "application/json" }, + ); + }; + + const handleReset = () => { + fetcher.submit( + { _action: "UPDATE_CUSTOM_THEME", newValue: null }, + { method: "post", encType: "application/json" }, + ); + }; + + return ( + + ); +} diff --git a/app/features/team/team-schemas.server.ts b/app/features/team/team-schemas.server.ts index fa206479a..f6ec1db1f 100644 --- a/app/features/team/team-schemas.server.ts +++ b/app/features/team/team-schemas.server.ts @@ -39,6 +39,13 @@ const deleteActionsSchema = z.object({ export const editTeamSchema = z.union([ deleteActionsSchema, + z.object({ + _action: _action("UPDATE_CUSTOM_THEME"), + newValue: z.preprocess( + (val) => (!val || val === "null" ? null : val), + themeInputSchema.nullable(), + ), + }), z.object({ _action: _action("EDIT"), name: z.string().min(TEAM.NAME_MIN_LENGTH).max(TEAM.NAME_MAX_LENGTH), diff --git a/app/features/team/team.module.css b/app/features/team/team.module.css index 6c7d287f2..9a4d239bc 100644 --- a/app/features/team/team.module.css +++ b/app/features/team/team.module.css @@ -307,6 +307,10 @@ font-size: var(--font-sm); } +.formDivider { + margin-block: var(--s-6); +} + @media screen and (min-width: 640px) { .bannerFlags { display: flex; diff --git a/app/root.tsx b/app/root.tsx index e79a4172e..e3c8d1aba 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -250,7 +250,7 @@ function Document({ lang={locale} dir={i18n.dir()} className={clsx(htmlThemeClass, "scrollbar")} - style={customThemeStyle} + style={Object.fromEntries(customThemeStyle)} > @@ -353,23 +353,25 @@ declare module "react-aria-components" { } } -function useCustomThemeVars(): React.CSSProperties | undefined { +function useCustomThemeVars() { const matches = useMatches(); - let styles: React.CSSProperties | undefined; + const styles: Map = new Map(); for (const match of matches) { const data = match.data as { customTheme?: CustomTheme } | undefined; if (data?.customTheme) { - const styleObj: Record = {}; - for (const [key, value] of Object.entries(data.customTheme)) { - // Skips size variables for themes that arent the user's own - if (match.id !== "root" && key.includes("--_size")) continue; - styleObj[key] = value; - } + // Skips size and border variables for themes that arent the user's own + if ( + match.id !== "root" && + (key.includes("--_size") || key.includes("--_border")) + ) + continue; + if (value === null) continue; - styles = styleObj as React.CSSProperties; + styles.set(key, value); + } } } diff --git a/locales/en/team.json b/locales/en/team.json index 3a9d7a5b8..643033248 100644 --- a/locales/en/team.json +++ b/locales/en/team.json @@ -42,6 +42,7 @@ "forms.info.tag": "Typically used before in-game name to indicate membership of a team (e.g. [TAG] PlayerName)", "forms.errors.duplicateName": "There is already a team with this name", "forms.errors.noOnlySpecialCharacters": "Team name can't be only special characters", + "forms.customTheme.header": "Custom Theme", "roster.teamFull": "Team is full", "roster.inviteLink.header": "Share invite link to add members", "roster.members.header": "Members",