Add custom theme support to orgs

This commit is contained in:
hfcRed
2026-08-04 14:08:34 -04:00
parent ff60e34c55
commit 0af513fb17
27 changed files with 182 additions and 40 deletions

View File

@@ -750,6 +750,7 @@ export interface TournamentOrganization {
socials: JSONColumnTypeNullable<string[]>;
avatarImgId: number | null;
isEstablished: Generated<DBBoolean>;
customTheme: JSONColumnTypeNullable<CustomTheme>;
}
export interface TournamentOrganizationMember {

View File

@@ -3,6 +3,7 @@ import { sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { Tables, TablesInsertable } from "~/db/tables";
import type { CustomTheme } from "~/db/tables-json";
import { actorId } from "~/features/auth/core/user.server";
import {
TIER_HISTORY_LENGTH,
@@ -68,6 +69,7 @@ export async function findBySlug(slug: string) {
"TournamentOrganization.socials",
"TournamentOrganization.slug",
"TournamentOrganization.isEstablished",
"TournamentOrganization.customTheme",
"TournamentOrganization.avatarImgId",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
@@ -760,6 +762,22 @@ export function updateIsEstablished(
.execute();
}
export function updateCustomTheme({
id,
customTheme,
}: {
id: number;
customTheme: CustomTheme | null;
}) {
return db
.updateTable("TournamentOrganization")
.set({
customTheme: customTheme ? JSON.stringify(customTheme) : null,
})
.where("id", "=", id)
.execute();
}
export function deleteById(organizationId: number) {
return db
.deleteFrom("TournamentOrganization")

View File

@@ -5,16 +5,19 @@ import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tou
import { parseFormDataWithImages } from "~/form/parse.server";
import { getServerTFunction } from "~/modules/i18n/i18next.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { tournamentOrganizationPage } from "~/utils/urls";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { organizationEditFormSchema } from "../tournament-organization-schemas";
import { organizationEditActionSchema } from "../tournament-organization-schemas";
import { organizationFromParams } from "../tournament-organization-utils.server";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = requireUser();
const result = await parseFormDataWithImages({
request,
schema: organizationEditFormSchema,
schema: organizationEditActionSchema,
});
if (!result.success) {
@@ -23,47 +26,71 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const data = result.data;
const t = getServerTFunction(["org"]);
const organization = await organizationFromParams(params);
requirePermission(organization, "EDIT");
if (
!data.members.some(
(member) => member.userId === user.id && member.role === "ADMIN",
)
) {
return {
fieldErrors: { members: t("org:edit.form.errors.noUnadmin") },
};
switch (data._action) {
case "UPDATE_CUSTOM_THEME": {
errorToastIfFalsy(
organization.isEstablished,
"Organization is not established",
);
await TournamentOrganizationRepository.updateCustomTheme({
id: organization.id,
customTheme: data.newValue ? clampThemeToGamut(data.newValue) : null,
});
return { ok: true };
}
case "EDIT": {
if (
!data.members.some(
(member) => member.userId === user.id && member.role === "ADMIN",
)
) {
const t = getServerTFunction(["org"]);
return {
fieldErrors: { members: t("org:edit.form.errors.noUnadmin") },
};
}
const socials = data.socials.filter((s) => s.length > 0);
const newOrganization = await TournamentOrganizationRepository.update({
id: organization.id,
name: data.name,
description: data.description,
socials: socials.length > 0 ? socials : null,
avatarImgId: data.logo,
members: data.members,
series: data.series,
badges: data.badges,
});
// in case members changed...
// 1) clear the participation info map (front page)
ShowcaseTournaments.clearParticipationInfoMap();
// 2) clear tournament data caches (so permission changes are shown immediately)
await clearUnfinalizedEventCaches(organization.id);
return redirect(
tournamentOrganizationPage({ organizationSlug: newOrganization.slug }),
);
}
default: {
assertUnreachable(data);
}
}
};
const socials = data.socials.filter((s) => s.length > 0);
const newOrganization = await TournamentOrganizationRepository.update({
id: organization.id,
name: data.name,
description: data.description,
socials: socials.length > 0 ? socials : null,
avatarImgId: data.logo,
members: data.members,
series: data.series,
badges: data.badges,
});
// in case members changed...
// 1) clear the participation info map (front page)
ShowcaseTournaments.clearParticipationInfoMap();
// 2) clear tournament data caches (so permission changes are shown immediately)
async function clearUnfinalizedEventCaches(organizationId: number) {
for (const tournament of await TournamentOrganizationRepository.findAllUnfinalizedEvents(
organization.id,
organizationId,
)) {
clearTournamentDataCache(tournament.id);
}
return redirect(
tournamentOrganizationPage({ organizationSlug: newOrganization.slug }),
);
};
}

View File

@@ -27,5 +27,7 @@ export async function loader({ params }: LoaderFunctionArgs) {
return {
organization,
badgeOptions: await badgeOptions(),
customTheme: organization.isEstablished ? organization.customTheme : null,
canSetCustomTheme: Boolean(organization.isEstablished),
};
}

View File

@@ -65,6 +65,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return {
organization,
customTheme: organization.isEstablished ? organization.customTheme : null,
events: series
? await TournamentOrganizationRepository.findPaginatedEventsBySeries({
organizationId: organization.id,

View File

@@ -37,6 +37,7 @@ export async function loader({ params }: LoaderFunctionArgs) {
return {
monthlyStats,
averageMonthlyParticipants,
customTheme: organization.isEstablished ? organization.customTheme : null,
};
}

View File

@@ -1,8 +1,12 @@
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
import { Divider } from "~/components/Divider";
import { Main } from "~/components/Main";
import { existingImage } from "~/form/image-field";
import { SendouForm } from "~/form/SendouForm";
import styles from "~/form/SendouForm.module.css";
import type { ThemeInput } from "~/utils/oklch-gamut";
import { action } from "../actions/org.$slug.edit.server";
import { loader } from "../loaders/org.$slug.edit.server";
import { handle, meta } from "../routes/org.$slug";
@@ -52,6 +56,48 @@ export default function TournamentOrganizationEditPage() {
</>
)}
</SendouForm>
{data.canSetCustomTheme ? (
<div className={styles.form}>
<Divider className="mt-10" smallText>
{t("org:edit.form.customTheme.title")}
</Divider>
<OrganizationCustomThemeSelector />
</div>
) : null}
</Main>
);
}
function OrganizationCustomThemeSelector() {
const data = 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={data.customTheme}
isSupporter={data.canSetCustomTheme}
isPersonalTheme={false}
hidePatreonInfo
onSave={handleSave}
onReset={handleReset}
fetcherState={fetcher.state}
/>
);
}

View File

@@ -18,7 +18,7 @@ import {
userSearch,
} from "~/form/fields";
import { mySlugify } from "~/utils/urls";
import { _action, id } from "~/utils/zod";
import { _action, id, themeInputSchema } from "~/utils/zod";
const orgNameField = textField({
label: "labels.name",
@@ -35,6 +35,7 @@ export const newOrganizationSchema = z.object({
});
export const organizationEditFormSchema = z.object({
_action: stringConstant("EDIT"),
name: orgNameField,
logo: image({ label: "labels.logo", autoValidate: true }),
description: textAreaOptional({
@@ -88,6 +89,19 @@ export const organizationEditFormSchema = z.object({
badges: badges({ label: "labels.orgBadges", maxCount: 50 }),
});
const updateOrganizationCustomThemeSchema = z.object({
_action: _action("UPDATE_CUSTOM_THEME"),
newValue: z.preprocess(
(val) => (!val || val === "null" ? null : val),
themeInputSchema.nullable(),
),
});
export const organizationEditActionSchema = z.union([
organizationEditFormSchema,
updateOrganizationCustomThemeSchema,
]);
export const banUserActionSchema = z.object({
_action: stringConstant("BAN_USER"),
userId: userSearch({ label: "labels.player" }),

View File

@@ -196,6 +196,10 @@
margin-block-start: var(--s-6);
}
.mt-10 {
margin-block-start: var(--s-10);
}
.mt-12 {
margin-block-start: var(--s-12);
}

View File

@@ -1,6 +1,6 @@
# Custom Theme System
The custom theme system lets Patreon supporters customize the sites colors, border radii, sizes, and border widths. Custom themes are created using as a small set of inputs which are expanded into a full set of CSS properties via gamut clamping.
The custom theme system lets Patreon supporters customize the sites colors, border radii, sizes, and border widths. Teams (with a supporter member) and established tournament organizations can also set a custom theme shown on their pages. Custom themes are created using as a small set of inputs which are expanded into a full set of CSS properties via gamut clamping.
## Files
@@ -52,7 +52,7 @@ You cannot always recover the original `baseChroma` from a stored `CustomTheme`.
### Size and border vars are for users only
`useCustomThemeVars()` in `root.tsx` only applies `--_size-*` and `--_border-width` from the users own theme. When viewing the page of another user or team, their theme only overrides color and radius variables, never size or border, to prevent layout shifts and accessibility issues.
`useCustomThemeVars()` in `root.tsx` only applies `--_size-*` and `--_border-width` from the users own theme. When viewing the page of another user, team, or tournament organization, their theme only overrides color and radius variables, never size or border, to prevent layout shifts and accessibility issues.
### The `--_base-c-0` slot is always near zero

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "Badges",
"edit.form.rewards.title": "Rewards",
"edit.form.errors.noUnadmin": "Can't remove yourself as an admin",
"edit.form.customTheme.title": "Custom theme",
"banned.title": "Banned players",
"banned.empty": "No players are currently banned from this organization.",
"banned.description": "Players who are banned cannot create or join teams in tournaments organized by this organization. This tab is only accessible to the admins of this organization.",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "Insignias",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "No se puede eliminar como admin",
"edit.form.customTheme.title": "",
"banned.title": "Jugadores baneados",
"banned.empty": "Actualmente no hay jugadores baneados en esta organización.",
"banned.description": "Los jugadores baneados no pueden crear ni unirse a equipos en los torneos organizados por esta organización. Esta pestaña solo es accesible para los admins de la organización.",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "Insignias",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "No se puede eliminar como admin",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "Badges",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "Vous ne pouvez pas vous supprimer en tant qu'administrateur",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "Medaglia",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "Non puoi rimuoverti dal ruolo di admin",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "Награды",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "Невозможно удалить собственную роль администратора",
"edit.form.customTheme.title": "",
"banned.title": "",
"banned.empty": "",
"banned.description": "",

View File

@@ -22,6 +22,7 @@
"edit.form.badges.title": "徽章",
"edit.form.rewards.title": "",
"edit.form.errors.noUnadmin": "您不能移除自己的管理员身份",
"edit.form.customTheme.title": "",
"banned.title": "被封禁的玩家",
"banned.empty": "当前没有玩家被此组织封禁。",
"banned.description": "被封禁的玩家无法在此组织举办的赛事中创建或加入队伍。此标签页仅对组织管理员开放。",

View File

@@ -0,0 +1,12 @@
import type { Kysely } from "kysely";
/** Lets established tournament organizations set a custom theme for their org and tournament pages */
export async function up(db: Kysely<any>): Promise<void> {
// kysely does not wrap sqlite migrations in a transaction, so do it here
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("TournamentOrganization")
.addColumn("customTheme", "text")
.execute();
});
}