mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-22 10:26:53 -05:00
Add theme tab to tournament
This commit is contained in:
@@ -534,6 +534,8 @@ export interface Tournament {
|
||||
vodsLastSyncAt: Generated<number | null>;
|
||||
/** How many times vods have been synced (automatic process that happens when tournament has concluded). */
|
||||
vodsSyncCount: Generated<number>;
|
||||
/** Custom theme shown on the tournament's pages. Only settable when the tournament is hosted by an established organization. */
|
||||
customTheme: JSONColumnTypeNullable<CustomTheme>;
|
||||
}
|
||||
|
||||
export interface SavedCalendarEvent {
|
||||
|
||||
@@ -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<typeof adminThemeActionSchema>({
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
54
app/features/tournament-admin/routes/to.$id.admin.theme.tsx
Normal file
54
app/features/tournament-admin/routes/to.$id.admin.theme.tsx
Normal file
@@ -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 <Redirect to={tournamentAdminPage(tournament.ctx.id)} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.form}>
|
||||
<CustomThemeSelector
|
||||
initialTheme={tournament.ctx.customTheme}
|
||||
isSupporter
|
||||
isPersonalTheme={false}
|
||||
hidePatreonInfo
|
||||
onSave={handleSave}
|
||||
onReset={handleReset}
|
||||
fetcherState={fetcher.state}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <Redirect to={tournamentPage(tournament.ctx.id)} />;
|
||||
@@ -148,6 +159,15 @@ export default function TournamentAdminLayout() {
|
||||
{t("tournament:admin.tab.brackets")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
{showThemeTab ? (
|
||||
<SendouTab
|
||||
id="theme"
|
||||
href={`${adminPage}/theme`}
|
||||
icon={<Palette />}
|
||||
>
|
||||
{t("tournament:admin.tab.theme")}
|
||||
</SendouTab>
|
||||
) : null}
|
||||
<SendouTab id="audit" href={`${adminPage}/audit`} icon={<History />}>
|
||||
{t("tournament:admin.tab.audit")}
|
||||
</SendouTab>
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -6162,6 +6162,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
],
|
||||
},
|
||||
ctx: {
|
||||
customTheme: null,
|
||||
id: 815,
|
||||
eventId: 2614,
|
||||
parentTournamentId: null,
|
||||
|
||||
@@ -1664,6 +1664,7 @@ export const SWIM_OR_SINK_167 = (
|
||||
],
|
||||
},
|
||||
ctx: {
|
||||
customTheme: null,
|
||||
id: 672,
|
||||
parentTournamentId: null,
|
||||
parentTournamentName: null,
|
||||
|
||||
@@ -265,6 +265,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
],
|
||||
},
|
||||
ctx: {
|
||||
customTheme: null,
|
||||
id: 891,
|
||||
eventId: 2698,
|
||||
tier: null,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -62,6 +62,7 @@ export const testTournament = ({
|
||||
id: 1,
|
||||
tags: null,
|
||||
organization: null,
|
||||
customTheme: null,
|
||||
tier: null,
|
||||
tentativeTier: null,
|
||||
parentTournamentId: null,
|
||||
|
||||
@@ -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<boolean>`"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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,8 +27,12 @@ import styles from "./to.$id.info.module.css";
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = (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({
|
||||
|
||||
@@ -29,8 +29,12 @@ import { useTournament } from "./to.$id";
|
||||
export { loader };
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = (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(
|
||||
|
||||
@@ -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<typeof loader>();
|
||||
const rawData = useLoaderData<typeof loader>().data;
|
||||
const data = React.useMemo(
|
||||
() => JSON.parse(rawData) as TournamentLoaderData,
|
||||
[rawData],
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "事件",
|
||||
|
||||
@@ -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<any>): Promise<void> {
|
||||
// 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<any>): Promise<void> {
|
||||
.alterTable("TournamentOrganization")
|
||||
.addColumn("customTheme", "text")
|
||||
.execute();
|
||||
|
||||
await trx.schema
|
||||
.alterTable("Tournament")
|
||||
.addColumn("customTheme", "text")
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user