diff --git a/app/db/tables.ts b/app/db/tables.ts index a9d84c494..0d593256a 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -534,6 +534,8 @@ export interface Tournament { vodsLastSyncAt: Generated; /** How many times vods have been synced (automatic process that happens when tournament has concluded). */ vodsSyncCount: Generated; + /** Custom theme shown on the tournament's pages. Only settable when the tournament is hosted by an established organization. */ + customTheme: JSONColumnTypeNullable; } export interface SavedCalendarEvent { diff --git a/app/features/tournament-admin/actions/to.$id.admin.theme.server.test.ts b/app/features/tournament-admin/actions/to.$id.admin.theme.server.test.ts new file mode 100644 index 000000000..f40e8fbe8 --- /dev/null +++ b/app/features/tournament-admin/actions/to.$id.admin.theme.server.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { REGULAR_USER_TEST_ID } from "~/db/seed/constants"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import invariant from "~/utils/invariant"; +import { clampThemeToGamut } from "~/utils/oklch-gamut"; +import { wrappedAction } from "~/utils/Test"; +import type { adminThemeActionSchema } from "../tournament-admin-schemas.server"; +import { action as _adminThemeAction } from "./to.$id.admin.theme.server"; + +const adminThemeAction = wrappedAction({ + action: _adminThemeAction, + isJsonSubmission: true, +}); + +const VALID_CUSTOM_THEME = { + baseHue: 180, + baseChroma: 0.05, + accentHue: 200, + accentChroma: 0.1, + chatHue: null, + radiusBox: 3, + radiusField: 2, + radiusSelector: 2, + borderWidth: 2, + sizeField: 1, + sizeSelector: 1, + sizeSpacing: 1, +} as const; + +const expectedStoredTheme = () => + JSON.parse(JSON.stringify(clampThemeToGamut(VALID_CUSTOM_THEME))); + +describe("tournament admin custom theme", () => { + let tournamentId: number; + + const createOrgTournament = async ({ isEstablished = true } = {}) => { + const organization = await TournamentOrganizationFactory.create( + { ownerId: REGULAR_USER_TEST_ID }, + { isEstablished }, + ); + const tournament = await TournamentFactory.create({ + authorId: REGULAR_USER_TEST_ID, + organizationId: organization.id, + }); + tournamentId = tournament.id; + }; + + const tournamentRow = async () => { + const tournament = await TournamentRepository.findById(tournamentId); + invariant(tournament, `No tournament with the id ${tournamentId}`); + + return tournament; + }; + + beforeEach(async () => { + await UserFactory.createRegular(); + }); + + it("sets a custom theme via UPDATE_CUSTOM_THEME", async () => { + await createOrgTournament(); + + const response = await adminThemeAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: VALID_CUSTOM_THEME, + }, + { user: "regular", params: { id: String(tournamentId) } }, + ); + + expect(response).toEqual({ ok: true }); + expect((await tournamentRow()).customTheme).toEqual(expectedStoredTheme()); + }); + + it("clears a custom theme via UPDATE_CUSTOM_THEME with null", async () => { + await createOrgTournament(); + + await adminThemeAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: VALID_CUSTOM_THEME, + }, + { user: "regular", params: { id: String(tournamentId) } }, + ); + + const response = await adminThemeAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: null, + }, + { user: "regular", params: { id: String(tournamentId) } }, + ); + + expect(response).toEqual({ ok: true }); + expect((await tournamentRow()).customTheme).toBeNull(); + }); + + it("prevents setting an invalid custom theme", async () => { + await createOrgTournament(); + + const response = await adminThemeAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: { + ...VALID_CUSTOM_THEME, + baseHue: 500, // Invalid: max is 360 + }, + }, + { user: "regular", params: { id: String(tournamentId) } }, + ); + + expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy(); + }); + + it("prevents setting a custom theme when the organization is not established", async () => { + await createOrgTournament({ isEstablished: false }); + + const response = await adminThemeAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: VALID_CUSTOM_THEME, + }, + { user: "regular", params: { id: String(tournamentId) } }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toContain("__error"); + expect((await tournamentRow()).customTheme).toBeNull(); + }); + + it("prevents setting a custom theme when the tournament has no organization", async () => { + const tournament = await TournamentFactory.create({ + authorId: REGULAR_USER_TEST_ID, + organizationId: null, + }); + tournamentId = tournament.id; + + const response = await adminThemeAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: VALID_CUSTOM_THEME, + }, + { user: "regular", params: { id: String(tournamentId) } }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toContain("__error"); + expect((await tournamentRow()).customTheme).toBeNull(); + }); +}); diff --git a/app/features/tournament-admin/actions/to.$id.admin.theme.server.ts b/app/features/tournament-admin/actions/to.$id.admin.theme.server.ts new file mode 100644 index 000000000..777fca1d8 --- /dev/null +++ b/app/features/tournament-admin/actions/to.$id.admin.theme.server.ts @@ -0,0 +1,48 @@ +import type { ActionFunction } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { parseFormData } from "~/form/parse.server"; +import { clampThemeToGamut } from "~/utils/oklch-gamut"; +import { errorToastIfFalsy, parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; +import { adminThemeActionSchema } from "../tournament-admin-schemas.server"; +import { requireTournamentAdmin } from "../tournament-admin-utils.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = requireUser(); + + const { id: tournamentId } = parseParams({ + params, + schema: idObject, + }); + const tournament = await tournamentFromDB({ tournamentId, user }); + + requireTournamentAdmin(tournament, user); + errorToastIfFalsy( + tournament.ctx.organization?.isEstablished, + "Tournament is not hosted by an established organization", + ); + + const result = await parseFormData({ + request, + schema: adminThemeActionSchema, + }); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + + await TournamentRepository.updateCustomTheme({ + tournamentId, + customTheme: result.data.newValue + ? clampThemeToGamut(result.data.newValue) + : null, + }); + + clearTournamentDataCache(tournamentId); + + return { ok: true }; +}; diff --git a/app/features/tournament-admin/routes/to.$id.admin.theme.tsx b/app/features/tournament-admin/routes/to.$id.admin.theme.tsx new file mode 100644 index 000000000..c7c447728 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.theme.tsx @@ -0,0 +1,54 @@ +import { useFetcher } from "react-router"; +import { CustomThemeSelector } from "~/components/CustomThemeSelector"; +import { Redirect } from "~/components/Redirect"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import styles from "~/form/SendouForm.module.css"; +import type { ThemeInput } from "~/utils/oklch-gamut"; +import { tournamentAdminPage } from "~/utils/urls"; + +export { action } from "../actions/to.$id.admin.theme.server"; + +export default function TournamentAdminThemePage() { + const user = useUser(); + const tournament = useTournament(); + const fetcher = useFetcher(); + + if ( + !tournament.isAdmin(user) || + !tournament.ctx.organization?.isEstablished + ) { + return ; + } + + 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-admin/routes/to.$id.admin.tsx b/app/features/tournament-admin/routes/to.$id.admin.tsx index 11f9934e9..d46d70691 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.tsx @@ -2,6 +2,7 @@ import clsx from "clsx"; import { History, ListOrdered, + Palette, SquarePen, Trophy, Tv, @@ -36,7 +37,14 @@ export { action } from "../actions/to.$id.admin.index.server"; const HORIZONTAL_TABS_BELOW = 720; -type AdminTab = "teams" | "seeds" | "staff" | "stream" | "brackets" | "audit"; +type AdminTab = + | "teams" + | "seeds" + | "staff" + | "stream" + | "brackets" + | "theme" + | "audit"; export default function TournamentAdminLayout() { const { t } = useTranslation(["tournament", "calendar"]); @@ -61,6 +69,9 @@ export default function TournamentAdminLayout() { : !tournament.isLeagueSignup || showEditBrackets; const showStreamTab = !tournament.ctx.isFinalized; const showSeedsTab = !tournament.hasStarted && !tournament.isLeagueSignup; + const showThemeTab = + tournament.isAdmin(user) && + Boolean(tournament.ctx.organization?.isEstablished); if (!tournament.isOrganizer(user)) { return ; @@ -148,6 +159,15 @@ export default function TournamentAdminLayout() { {t("tournament:admin.tab.brackets")} ) : null} + {showThemeTab ? ( + } + > + {t("tournament:admin.tab.theme")} + + ) : null} }> {t("tournament:admin.tab.audit")} diff --git a/app/features/tournament-admin/tournament-admin-schemas.server.ts b/app/features/tournament-admin/tournament-admin-schemas.server.ts index 98bc62267..7f4d59c31 100644 --- a/app/features/tournament-admin/tournament-admin-schemas.server.ts +++ b/app/features/tournament-admin/tournament-admin-schemas.server.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; -import { _action, id, safeJSONParse } from "~/utils/zod"; +import { _action, id, safeJSONParse, themeInputSchema } from "~/utils/zod"; import { bracketProgressionSchema } from "../calendar/calendar-schemas"; import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server"; import { adminStaffFormSchema } from "./tournament-admin-staff-schemas"; @@ -97,3 +97,11 @@ export const adminSeedsActionSchema = z.union([ ), }), ]); + +export const adminThemeActionSchema = z.object({ + _action: _action("UPDATE_CUSTOM_THEME"), + newValue: z.preprocess( + (val) => (!val || val === "null" ? null : val), + themeInputSchema.nullable(), + ), +}); diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index 9e3c34c7f..ef322b7e0 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -6162,6 +6162,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ ], }, ctx: { + customTheme: null, id: 815, eventId: 2614, parentTournamentId: null, diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index e38a27899..dc6468f8d 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -1664,6 +1664,7 @@ export const SWIM_OR_SINK_167 = ( ], }, ctx: { + customTheme: null, id: 672, parentTournamentId: null, parentTournamentName: null, diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts index 2e67994fb..573cd1aec 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -265,6 +265,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ ], }, ctx: { + customTheme: null, id: 891, eventId: 2698, tier: null, diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 28e848fef..db2cba7fa 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -1186,6 +1186,7 @@ export const PADDLING_POOL_257 = () => ], }, ctx: { + customTheme: null, id: 27, organization: null, tier: null, @@ -7976,6 +7977,7 @@ export const PADDLING_POOL_255 = () => ], }, ctx: { + customTheme: null, id: 18, organization: null, tier: null, @@ -14746,6 +14748,7 @@ export const IN_THE_ZONE_32 = ({ ], }, ctx: { + customTheme: null, id: 11, parentTournamentId: null, parentTournamentName: null, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index cfe53ed5c..740d5f8ae 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -62,6 +62,7 @@ export const testTournament = ({ id: 1, tags: null, organization: null, + customTheme: null, tier: null, tentativeTier: null, parentTournamentId: null, diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 1774e32b9..7d73c072f 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -6,6 +6,7 @@ import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; import type { CastedMatchesInfo, + CustomTheme, PreparedMaps, TournamentSettings, } from "~/db/tables-json"; @@ -57,6 +58,7 @@ export async function findById(id: number) { "Tournament.castTwitchAccounts", "Tournament.castedMatchesInfo", "Tournament.mapPickingStyle", + "Tournament.customTheme", sql`"Tournament"."rules" is not null`.as("hasRules"), "Tournament.parentTournamentId", eb @@ -886,6 +888,22 @@ export function upsertPreparedMaps({ }); } +export function updateCustomTheme({ + tournamentId, + customTheme, +}: { + tournamentId: number; + customTheme: CustomTheme | null; +}) { + return db + .updateTable("Tournament") + .set({ + customTheme: customTheme ? JSON.stringify(customTheme) : null, + }) + .where("id", "=", tournamentId) + .execute(); +} + export function updateCastTwitchAccounts({ tournamentId, castTwitchAccounts, diff --git a/app/features/tournament/loaders/to.$id.server.ts b/app/features/tournament/loaders/to.$id.server.ts index d3a64e405..141c1eab6 100644 --- a/app/features/tournament/loaders/to.$id.server.ts +++ b/app/features/tournament/loaders/to.$id.server.ts @@ -79,19 +79,26 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { subDays(new Date(), TOURNAMENT.VOD_VISIBILITY_DAYS), ); - // skip expensive rr7 data serialization (hot path loader) - return JSON.stringify({ - tournament, - hasChildTournaments, - friendCodes: showFriendCodes - ? await TournamentRepository.findFriendCodesByTournamentId(tournamentId) - : undefined, - preparedMaps: - isTournamentOrganizer && !tournament.ctx.isFinalized - ? await TournamentRepository.findPreparedMapsById(tournamentId) + return { + customTheme: tournament.ctx.organization?.isEstablished + ? tournament.ctx.customTheme + : null, + // skip expensive rr7 data serialization (hot path loader) + data: JSON.stringify({ + tournament, + hasChildTournaments, + friendCodes: showFriendCodes + ? await TournamentRepository.findFriendCodesByTournamentId(tournamentId) : undefined, - vods: showVods - ? await TournamentMatchVodRepository.findVodsByTournamentId(tournamentId) - : undefined, - }); + preparedMaps: + isTournamentOrganizer && !tournament.ctx.isFinalized + ? await TournamentRepository.findPreparedMapsById(tournamentId) + : undefined, + vods: showVods + ? await TournamentMatchVodRepository.findVodsByTournamentId( + tournamentId, + ) + : undefined, + }), + }; }; diff --git a/app/features/tournament/routes/to.$id.info.tsx b/app/features/tournament/routes/to.$id.info.tsx index 636bf3818..0166e58cd 100644 --- a/app/features/tournament/routes/to.$id.info.tsx +++ b/app/features/tournament/routes/to.$id.info.tsx @@ -27,8 +27,12 @@ import styles from "./to.$id.info.module.css"; export { action, loader }; export const meta: MetaFunction = (args) => { - const tournamentData = JSON.parse(args.matches[1].loaderData as any) - ?.tournament as TournamentData | undefined; + const rawData = (args.matches[1].loaderData as { data: string } | undefined) + ?.data; + const tournamentData = rawData + ? (JSON.parse(rawData)?.tournament as TournamentData | undefined) + : undefined; + if (!tournamentData) return []; return metaTags({ diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx index 537593a23..5797852d8 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.tsx +++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx @@ -29,8 +29,12 @@ import { useTournament } from "./to.$id"; export { loader }; export const meta: MetaFunction = (args) => { - const tournamentData = JSON.parse(args.matches[1].loaderData as any) - ?.tournament as TournamentData; + const rawData = (args.matches[1].loaderData as { data: string } | undefined) + ?.data; + const tournamentData = rawData + ? (JSON.parse(rawData)?.tournament as TournamentData | undefined) + : undefined; + if (!args.loaderData || !tournamentData) return []; const team = tournamentData.ctx.teams.find( diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 50f0873e3..ae162c253 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -31,7 +31,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (args) => { }; export const meta: MetaFunction = (args) => { - const rawData = args.loaderData as string | undefined; + const rawData = (args.loaderData as { data: string } | undefined)?.data; if (!rawData) return []; @@ -51,7 +51,7 @@ export const meta: MetaFunction = (args) => { export const handle: SendouRouteHandle = { i18n: ["tournament", "calendar"], breadcrumb: ({ match }) => { - const rawData = match.loaderData as string | undefined; + const rawData = (match.loaderData as { data: string } | undefined)?.data; if (!rawData) return []; @@ -87,7 +87,7 @@ export default function TournamentLayoutShell() { } export function TournamentLayout() { - const rawData = useLoaderData(); + const rawData = useLoaderData().data; const data = React.useMemo( () => JSON.parse(rawData) as TournamentLoaderData, [rawData], diff --git a/app/routes.ts b/app/routes.ts index f3c288f23..aab536333 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -184,6 +184,7 @@ export default [ "brackets", "features/tournament-admin/routes/to.$id.admin.brackets.tsx", ), + route("theme", "features/tournament-admin/routes/to.$id.admin.theme.tsx"), route("audit", "features/tournament-admin/routes/to.$id.admin.audit.tsx"), ]), route("results", "features/tournament/routes/to.$id.results.tsx"), diff --git a/docs/styles.md b/docs/styles.md index d442d662c..f9afc254b 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. 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. +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, and tournaments hosted by an established organization can set one per tournament via the admin page's theme tab. 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, team, or tournament organization, 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, a team, a tournament organization, or a tournament, 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/tournament.json b/locales/da/tournament.json index 5b0deab6d..472158197 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -82,6 +82,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 4788bfee6..8f3bf51d9 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -82,6 +82,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index b1747cf4f..18914ce96 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -82,6 +82,7 @@ "admin.tab.staff": "Staff", "admin.tab.stream": "Stream", "admin.tab.brackets": "Brackets", + "admin.tab.theme": "Theme", "admin.tab.audit": "Audit log", "admin.audit.empty": "No events yet", "admin.audit.filter.event": "Event", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 77d3df3ff..08186e7f1 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -83,6 +83,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 6e04fcfda..b0dd495b5 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -83,6 +83,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index a567af998..0a90a3db3 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -83,6 +83,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 5dfdd1033..f5a795c52 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -83,6 +83,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index cc77dc741..e0ed8a897 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -83,6 +83,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index a7b63cd84..8aa2f93d2 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -83,6 +83,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 9e32f75a5..8480fd25d 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -80,6 +80,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 206a03945..207282094 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -80,6 +80,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 71b5963a0..46a0835e6 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -82,6 +82,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index ef79292d9..fc62379b0 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -84,6 +84,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 6e6cb3274..7c0178a00 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -83,6 +83,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 447a01c45..8b720f4da 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -84,6 +84,7 @@ "admin.tab.staff": "", "admin.tab.stream": "", "admin.tab.brackets": "", + "admin.tab.theme": "", "admin.tab.audit": "", "admin.audit.empty": "", "admin.audit.filter.event": "", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 6aba89afa..97e7cf552 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -81,6 +81,7 @@ "admin.tab.staff": "工作人员", "admin.tab.stream": "直播", "admin.tab.brackets": "对战表", + "admin.tab.theme": "", "admin.tab.audit": "操作日志", "admin.audit.empty": "暂无事件", "admin.audit.filter.event": "事件", diff --git a/migrations/20260804000000-tournament-organization-custom-theme.ts b/migrations/20260804000000-tournament-organization-custom-theme.ts index 46c81a9b2..25097179d 100644 --- a/migrations/20260804000000-tournament-organization-custom-theme.ts +++ b/migrations/20260804000000-tournament-organization-custom-theme.ts @@ -1,6 +1,7 @@ import type { Kysely } from "kysely"; -/** Lets established tournament organizations set a custom theme for their org and tournament pages */ +/** Lets established tournament organizations set a custom theme for their org pages + * and per tournament for the tournaments they host */ 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) => { @@ -8,5 +9,10 @@ export async function up(db: Kysely): Promise { .alterTable("TournamentOrganization") .addColumn("customTheme", "text") .execute(); + + await trx.schema + .alterTable("Tournament") + .addColumn("customTheme", "text") + .execute(); }); }