Add custom theme to teams

This commit is contained in:
hfcRed 2026-03-04 19:39:29 +01:00
parent 2b4c6a65ab
commit ae3349ec2d
8 changed files with 109 additions and 14 deletions

View File

@ -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,

View File

@ -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);

View File

@ -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),
};
};

View File

@ -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<typeof loader>();
const { team, canAddCustomizedColors } = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
@ -67,6 +70,14 @@ export default function EditTeamPage() {
</SubmitButton>
<FormErrors namespace="team" />
</Form>
{canAddCustomizedColors ? (
<>
<Divider className={styles.formDivider} smallText>
{t("team:forms.customTheme.header")}
</Divider>
<TeamCustomThemeSelector />
</>
) : null}
</div>
</Main>
);
@ -235,3 +246,36 @@ function BioTextarea() {
</div>
);
}
function TeamCustomThemeSelector() {
const { customTheme, canAddCustomizedColors } =
useLoaderData<typeof loader>();
const fetcher = useFetcher();
const handleSave = (themeInput: ThemeInput) => {
fetcher.submit(
{
_action: "UPDATE_CUSTOM_THEME",
newValue: themeInput,
} as unknown as Parameters<typeof fetcher.submit>[0],
{ method: "post", encType: "application/json" },
);
};
const handleReset = () => {
fetcher.submit(
{ _action: "UPDATE_CUSTOM_THEME", newValue: null },
{ method: "post", encType: "application/json" },
);
};
return (
<CustomThemeSelector
initialTheme={customTheme}
isSupporter={canAddCustomizedColors}
isPersonalTheme={false}
onSave={handleSave}
onReset={handleReset}
/>
);
}

View File

@ -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),

View File

@ -307,6 +307,10 @@
font-size: var(--font-sm);
}
.formDivider {
margin-block: var(--s-6);
}
@media screen and (min-width: 640px) {
.bannerFlags {
display: flex;

View File

@ -250,7 +250,7 @@ function Document({
lang={locale}
dir={i18n.dir()}
className={clsx(htmlThemeClass, "scrollbar")}
style={customThemeStyle}
style={Object.fromEntries(customThemeStyle)}
>
<head>
<meta charSet="utf-8" />
@ -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<string, number> = new Map();
for (const match of matches) {
const data = match.data as { customTheme?: CustomTheme } | undefined;
if (data?.customTheme) {
const styleObj: Record<string, number> = {};
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);
}
}
}

View File

@ -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",