From 0af513fb174a2e15eb743312ec9cc657aa8d5449 Mon Sep 17 00:00:00 2001 From: hfcRed Date: Tue, 4 Aug 2026 14:08:34 -0400 Subject: [PATCH] Add custom theme support to orgs --- app/db/tables.ts | 1 + ...TournamentOrganizationRepository.server.ts | 18 ++++ .../actions/org.$slug.edit.server.ts | 99 ++++++++++++------- .../loaders/org.$slug.edit.server.ts | 2 + .../loaders/org.$slug.server.ts | 1 + .../loaders/org.$slug.stats.server.ts | 1 + .../routes/org.$slug.edit.tsx | 48 ++++++++- .../tournament-organization-schemas.ts | 16 ++- app/styles/utils.css | 4 + docs/styles.md | 4 +- locales/da/org.json | 1 + locales/de/org.json | 1 + locales/en/org.json | 1 + locales/es-ES/org.json | 1 + locales/es-US/org.json | 1 + locales/fr-CA/org.json | 1 + locales/fr-EU/org.json | 1 + locales/he/org.json | 1 + locales/it/org.json | 1 + locales/ja/org.json | 1 + locales/ko/org.json | 1 + locales/nl/org.json | 1 + locales/pl/org.json | 1 + locales/pt-BR/org.json | 1 + locales/ru/org.json | 1 + locales/zh/org.json | 1 + ...00-tournament-organization-custom-theme.ts | 12 +++ 27 files changed, 182 insertions(+), 40 deletions(-) create mode 100644 migrations/20260804000000-tournament-organization-custom-theme.ts diff --git a/app/db/tables.ts b/app/db/tables.ts index f14958144..a9d84c494 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -750,6 +750,7 @@ export interface TournamentOrganization { socials: JSONColumnTypeNullable; avatarImgId: number | null; isEstablished: Generated; + customTheme: JSONColumnTypeNullable; } export interface TournamentOrganizationMember { diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index e7ebd36aa..4a860692a 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -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") diff --git a/app/features/tournament-organization/actions/org.$slug.edit.server.ts b/app/features/tournament-organization/actions/org.$slug.edit.server.ts index 31c02f52a..8ae9c1b99 100644 --- a/app/features/tournament-organization/actions/org.$slug.edit.server.ts +++ b/app/features/tournament-organization/actions/org.$slug.edit.server.ts @@ -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 }), - ); -}; +} diff --git a/app/features/tournament-organization/loaders/org.$slug.edit.server.ts b/app/features/tournament-organization/loaders/org.$slug.edit.server.ts index 023a876d8..40ebf2d39 100644 --- a/app/features/tournament-organization/loaders/org.$slug.edit.server.ts +++ b/app/features/tournament-organization/loaders/org.$slug.edit.server.ts @@ -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), }; } diff --git a/app/features/tournament-organization/loaders/org.$slug.server.ts b/app/features/tournament-organization/loaders/org.$slug.server.ts index 9c040df11..ab2502bd3 100644 --- a/app/features/tournament-organization/loaders/org.$slug.server.ts +++ b/app/features/tournament-organization/loaders/org.$slug.server.ts @@ -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, diff --git a/app/features/tournament-organization/loaders/org.$slug.stats.server.ts b/app/features/tournament-organization/loaders/org.$slug.stats.server.ts index 472c22df6..1edd737e4 100644 --- a/app/features/tournament-organization/loaders/org.$slug.stats.server.ts +++ b/app/features/tournament-organization/loaders/org.$slug.stats.server.ts @@ -37,6 +37,7 @@ export async function loader({ params }: LoaderFunctionArgs) { return { monthlyStats, averageMonthlyParticipants, + customTheme: organization.isEstablished ? organization.customTheme : null, }; } diff --git a/app/features/tournament-organization/routes/org.$slug.edit.tsx b/app/features/tournament-organization/routes/org.$slug.edit.tsx index 22d9a303e..0b618b813 100644 --- a/app/features/tournament-organization/routes/org.$slug.edit.tsx +++ b/app/features/tournament-organization/routes/org.$slug.edit.tsx @@ -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() { )} + {data.canSetCustomTheme ? ( +
+ + {t("org:edit.form.customTheme.title")} + + +
+ ) : null} ); } + +function OrganizationCustomThemeSelector() { + const data = 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/tournament-organization/tournament-organization-schemas.ts b/app/features/tournament-organization/tournament-organization-schemas.ts index b1bd6d840..da2c81b79 100644 --- a/app/features/tournament-organization/tournament-organization-schemas.ts +++ b/app/features/tournament-organization/tournament-organization-schemas.ts @@ -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" }), diff --git a/app/styles/utils.css b/app/styles/utils.css index ea6e9328a..9d94287f1 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -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); } diff --git a/docs/styles.md b/docs/styles.md index 8029348f9..d442d662c 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -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 diff --git a/locales/da/org.json b/locales/da/org.json index 95e15b4ce..0f44914fc 100644 --- a/locales/da/org.json +++ b/locales/da/org.json @@ -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": "", diff --git a/locales/de/org.json b/locales/de/org.json index 95e15b4ce..0f44914fc 100644 --- a/locales/de/org.json +++ b/locales/de/org.json @@ -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": "", diff --git a/locales/en/org.json b/locales/en/org.json index 4fe5ee04d..db72ec4b7 100644 --- a/locales/en/org.json +++ b/locales/en/org.json @@ -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.", diff --git a/locales/es-ES/org.json b/locales/es-ES/org.json index c32e5d301..79624c803 100644 --- a/locales/es-ES/org.json +++ b/locales/es-ES/org.json @@ -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.", diff --git a/locales/es-US/org.json b/locales/es-US/org.json index f0fff0396..41d07a58d 100644 --- a/locales/es-US/org.json +++ b/locales/es-US/org.json @@ -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": "", diff --git a/locales/fr-CA/org.json b/locales/fr-CA/org.json index 95e15b4ce..0f44914fc 100644 --- a/locales/fr-CA/org.json +++ b/locales/fr-CA/org.json @@ -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": "", diff --git a/locales/fr-EU/org.json b/locales/fr-EU/org.json index f7f224059..77258e914 100644 --- a/locales/fr-EU/org.json +++ b/locales/fr-EU/org.json @@ -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": "", diff --git a/locales/he/org.json b/locales/he/org.json index 95e15b4ce..0f44914fc 100644 --- a/locales/he/org.json +++ b/locales/he/org.json @@ -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": "", diff --git a/locales/it/org.json b/locales/it/org.json index 9c1e1480d..08cfb61f0 100644 --- a/locales/it/org.json +++ b/locales/it/org.json @@ -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": "", diff --git a/locales/ja/org.json b/locales/ja/org.json index 95e15b4ce..0f44914fc 100644 --- a/locales/ja/org.json +++ b/locales/ja/org.json @@ -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": "", diff --git a/locales/ko/org.json b/locales/ko/org.json index 95e15b4ce..0f44914fc 100644 --- a/locales/ko/org.json +++ b/locales/ko/org.json @@ -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": "", diff --git a/locales/nl/org.json b/locales/nl/org.json index 95e15b4ce..0f44914fc 100644 --- a/locales/nl/org.json +++ b/locales/nl/org.json @@ -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": "", diff --git a/locales/pl/org.json b/locales/pl/org.json index 95e15b4ce..0f44914fc 100644 --- a/locales/pl/org.json +++ b/locales/pl/org.json @@ -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": "", diff --git a/locales/pt-BR/org.json b/locales/pt-BR/org.json index 95e15b4ce..0f44914fc 100644 --- a/locales/pt-BR/org.json +++ b/locales/pt-BR/org.json @@ -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": "", diff --git a/locales/ru/org.json b/locales/ru/org.json index 2029e5b7d..0817443b9 100644 --- a/locales/ru/org.json +++ b/locales/ru/org.json @@ -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": "", diff --git a/locales/zh/org.json b/locales/zh/org.json index 96350f243..903e030d7 100644 --- a/locales/zh/org.json +++ b/locales/zh/org.json @@ -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": "被封禁的玩家无法在此组织举办的赛事中创建或加入队伍。此标签页仅对组织管理员开放。", diff --git a/migrations/20260804000000-tournament-organization-custom-theme.ts b/migrations/20260804000000-tournament-organization-custom-theme.ts new file mode 100644 index 000000000..46c81a9b2 --- /dev/null +++ b/migrations/20260804000000-tournament-organization-custom-theme.ts @@ -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): Promise { + // 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(); + }); +}