mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
507
apps/web-react/app/components/CustomThemeSelector.tsx
Normal file
507
apps/web-react/app/components/CustomThemeSelector.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
import { Check, Clipboard, PencilLine } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CustomTheme } from "~/db/tables-json";
|
||||
import {
|
||||
CUSTOM_THEME_VARS,
|
||||
type CustomThemeVar,
|
||||
} from "~/features/theme/theme-constants";
|
||||
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
|
||||
import {
|
||||
ACCENT_CHROMA_MULTIPLIERS,
|
||||
BASE_CHROMA_MULTIPLIERS,
|
||||
clampThemeToGamut,
|
||||
type ThemeInput,
|
||||
} from "~/utils/oklch-gamut";
|
||||
import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/zod";
|
||||
import styles from "./CustomThemeSelector.module.css";
|
||||
import { Divider } from "./Divider";
|
||||
import { LinkButton, SendouButton } from "./elements/Button";
|
||||
import { SendouSwitch } from "./elements/Switch";
|
||||
import { Label } from "./Label";
|
||||
|
||||
const COLOR_SLIDERS = [
|
||||
{
|
||||
id: "base-hue",
|
||||
inputKey: "baseHue",
|
||||
min: THEME_INPUT_LIMITS.BASE_HUE_MIN,
|
||||
max: THEME_INPUT_LIMITS.BASE_HUE_MAX,
|
||||
step: 1,
|
||||
labelKey: "baseHue",
|
||||
isHue: true,
|
||||
},
|
||||
{
|
||||
id: "base-chroma",
|
||||
inputKey: "baseChroma",
|
||||
min: THEME_INPUT_LIMITS.BASE_CHROMA_MIN,
|
||||
max: THEME_INPUT_LIMITS.BASE_CHROMA_MAX,
|
||||
step: 0.001,
|
||||
labelKey: "baseChroma",
|
||||
isHue: false,
|
||||
},
|
||||
{
|
||||
id: "accent-hue",
|
||||
inputKey: "accentHue",
|
||||
min: THEME_INPUT_LIMITS.ACCENT_HUE_MIN,
|
||||
max: THEME_INPUT_LIMITS.ACCENT_HUE_MAX,
|
||||
step: 1,
|
||||
labelKey: "accentHue",
|
||||
isHue: true,
|
||||
},
|
||||
{
|
||||
id: "accent-chroma",
|
||||
inputKey: "accentChroma",
|
||||
min: THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN,
|
||||
max: THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX,
|
||||
step: 0.01,
|
||||
labelKey: "accentChroma",
|
||||
isHue: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const RADIUS_SLIDERS = [
|
||||
{
|
||||
id: "radius-box",
|
||||
inputKey: "radiusBox",
|
||||
min: THEME_INPUT_LIMITS.RADIUS_MIN,
|
||||
max: THEME_INPUT_LIMITS.RADIUS_MAX,
|
||||
step: THEME_INPUT_LIMITS.RADIUS_STEP,
|
||||
labelKey: "boxes",
|
||||
},
|
||||
{
|
||||
id: "radius-field",
|
||||
inputKey: "radiusField",
|
||||
min: THEME_INPUT_LIMITS.RADIUS_MIN,
|
||||
max: THEME_INPUT_LIMITS.RADIUS_MAX,
|
||||
step: THEME_INPUT_LIMITS.RADIUS_STEP,
|
||||
labelKey: "fields",
|
||||
},
|
||||
{
|
||||
id: "radius-selector",
|
||||
inputKey: "radiusSelector",
|
||||
min: THEME_INPUT_LIMITS.RADIUS_MIN,
|
||||
max: THEME_INPUT_LIMITS.RADIUS_MAX,
|
||||
step: THEME_INPUT_LIMITS.RADIUS_STEP,
|
||||
labelKey: "selectors",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const BORDER_SLIDERS = [
|
||||
{
|
||||
id: "border-width",
|
||||
inputKey: "borderWidth",
|
||||
min: THEME_INPUT_LIMITS.BORDER_WIDTH_MIN,
|
||||
max: THEME_INPUT_LIMITS.BORDER_WIDTH_MAX,
|
||||
step: THEME_INPUT_LIMITS.BORDER_WIDTH_STEP,
|
||||
labelKey: "borderWidth",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SIZE_SLIDERS = [
|
||||
{
|
||||
id: "size-field",
|
||||
inputKey: "sizeField",
|
||||
min: THEME_INPUT_LIMITS.SIZE_MIN,
|
||||
max: THEME_INPUT_LIMITS.SIZE_MAX,
|
||||
step: THEME_INPUT_LIMITS.SIZE_STEP,
|
||||
labelKey: "fields",
|
||||
},
|
||||
{
|
||||
id: "size-selector",
|
||||
inputKey: "sizeSelector",
|
||||
min: THEME_INPUT_LIMITS.SIZE_MIN,
|
||||
max: THEME_INPUT_LIMITS.SIZE_MAX,
|
||||
step: THEME_INPUT_LIMITS.SIZE_STEP,
|
||||
labelKey: "selectors",
|
||||
},
|
||||
{
|
||||
id: "size-spacing",
|
||||
inputKey: "sizeSpacing",
|
||||
min: THEME_INPUT_LIMITS.SIZE_MIN,
|
||||
max: THEME_INPUT_LIMITS.SIZE_MAX,
|
||||
step: THEME_INPUT_LIMITS.SIZE_STEP,
|
||||
labelKey: "spacings",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type ThemeInputKey =
|
||||
| (typeof COLOR_SLIDERS)[number]["inputKey"]
|
||||
| (typeof RADIUS_SLIDERS)[number]["inputKey"]
|
||||
| (typeof BORDER_SLIDERS)[number]["inputKey"]
|
||||
| (typeof SIZE_SLIDERS)[number]["inputKey"]
|
||||
| "chatHue";
|
||||
|
||||
const THEME_STRING_KEYS: readonly ThemeInputKey[] = [
|
||||
...COLOR_SLIDERS.map((s) => s.inputKey),
|
||||
...RADIUS_SLIDERS.map((s) => s.inputKey),
|
||||
...BORDER_SLIDERS.map((s) => s.inputKey),
|
||||
...SIZE_SLIDERS.map((s) => s.inputKey),
|
||||
"chatHue",
|
||||
];
|
||||
|
||||
function themeInputToString(input: ThemeInput): string {
|
||||
return THEME_STRING_KEYS.map((key) => {
|
||||
const value = input[key];
|
||||
return value === null ? "_" : String(value);
|
||||
}).join(";");
|
||||
}
|
||||
|
||||
function themeInputFromString(str: string): ThemeInput | null {
|
||||
const parts = str.split(";");
|
||||
if (parts.length !== THEME_STRING_KEYS.length) return null;
|
||||
|
||||
const raw: Record<string, number | null> = {};
|
||||
for (let i = 0; i < THEME_STRING_KEYS.length; i++) {
|
||||
const key = THEME_STRING_KEYS[i];
|
||||
const part = parts[i].trim();
|
||||
|
||||
if (key === "chatHue" && part === "_") {
|
||||
raw[key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const num = Number(part);
|
||||
if (Number.isNaN(num)) return null;
|
||||
raw[key] = num;
|
||||
}
|
||||
|
||||
const parsed = themeInputSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME_INPUT: ThemeInput = {
|
||||
baseHue: 268,
|
||||
baseChroma: 0.05,
|
||||
accentHue: 253,
|
||||
accentChroma: 0.24,
|
||||
chatHue: null,
|
||||
radiusBox: 3,
|
||||
radiusField: 2,
|
||||
radiusSelector: 2,
|
||||
borderWidth: 2,
|
||||
sizeField: 1,
|
||||
sizeSelector: 1,
|
||||
sizeSpacing: 1,
|
||||
};
|
||||
|
||||
function themeInputFromCustomTheme(customTheme: CustomTheme): ThemeInput {
|
||||
return {
|
||||
baseHue: customTheme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue,
|
||||
baseChroma:
|
||||
typeof customTheme["--_base-c-2"] === "number"
|
||||
? customTheme["--_base-c-2"] / BASE_CHROMA_MULTIPLIERS[2]
|
||||
: DEFAULT_THEME_INPUT.baseChroma,
|
||||
accentHue: customTheme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue,
|
||||
accentChroma:
|
||||
typeof customTheme["--_acc-c-2"] === "number"
|
||||
? customTheme["--_acc-c-2"] / ACCENT_CHROMA_MULTIPLIERS[2]
|
||||
: DEFAULT_THEME_INPUT.accentChroma,
|
||||
chatHue: customTheme["--_chat-h"],
|
||||
radiusBox: customTheme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox,
|
||||
radiusField:
|
||||
customTheme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField,
|
||||
radiusSelector:
|
||||
customTheme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector,
|
||||
borderWidth:
|
||||
customTheme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth,
|
||||
sizeField: customTheme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField,
|
||||
sizeSelector:
|
||||
customTheme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector,
|
||||
sizeSpacing:
|
||||
customTheme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing,
|
||||
};
|
||||
}
|
||||
|
||||
function applyThemeInput(input: ThemeInput) {
|
||||
const clampedTheme = clampThemeToGamut(input);
|
||||
|
||||
for (const [key, value] of Object.entries(clampedTheme)) {
|
||||
document.documentElement.style.setProperty(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function ThemeSlider({
|
||||
id,
|
||||
inputKey,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
label,
|
||||
isHue,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
inputKey: ThemeInputKey;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
label: string;
|
||||
isHue?: boolean;
|
||||
value: number;
|
||||
onChange: (inputKey: ThemeInputKey, value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.themeSliderRow}>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<input
|
||||
id={id}
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(inputKey, Number(e.target.value))}
|
||||
className={isHue ? styles.hueSlider : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomThemeSelector({
|
||||
initialTheme,
|
||||
isSupporter,
|
||||
isPersonalTheme,
|
||||
onSave,
|
||||
onReset,
|
||||
hidePatreonInfo,
|
||||
fetcherState,
|
||||
}: {
|
||||
initialTheme: CustomTheme | null | undefined;
|
||||
isSupporter: boolean;
|
||||
isPersonalTheme: boolean;
|
||||
onSave: (themeInput: ThemeInput) => void;
|
||||
onReset: () => void;
|
||||
hidePatreonInfo?: boolean;
|
||||
fetcherState?: "idle" | "submitting" | "loading";
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const initialThemeInput = initialTheme
|
||||
? themeInputFromCustomTheme(initialTheme)
|
||||
: DEFAULT_THEME_INPUT;
|
||||
|
||||
const [themeInput, setThemeInput] =
|
||||
React.useState<ThemeInput>(initialThemeInput);
|
||||
|
||||
const handleSliderChange = (inputKey: ThemeInputKey, value: number) => {
|
||||
const updatedInput = { ...themeInput, [inputKey]: value };
|
||||
setThemeInput(updatedInput);
|
||||
applyThemeInput(updatedInput);
|
||||
};
|
||||
|
||||
const chatHueEnabled = themeInput.chatHue !== null;
|
||||
|
||||
const handleChatHueToggle = (isSelected: boolean) => {
|
||||
const updatedInput = {
|
||||
...themeInput,
|
||||
chatHue: isSelected ? 0 : null,
|
||||
};
|
||||
setThemeInput(updatedInput);
|
||||
applyThemeInput(updatedInput);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(themeInput);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setThemeInput(DEFAULT_THEME_INPUT);
|
||||
CUSTOM_THEME_VARS.forEach((varDef: CustomThemeVar) => {
|
||||
document.documentElement.style.removeProperty(varDef);
|
||||
});
|
||||
onReset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.customThemeSelector}>
|
||||
{hidePatreonInfo ? null : (
|
||||
<div
|
||||
className={
|
||||
isSupporter
|
||||
? styles.customThemeSelectorSupporter
|
||||
: styles.customThemeSelectorNoSupporter
|
||||
}
|
||||
>
|
||||
<div className={styles.customThemeSelectorInfo}>
|
||||
<p>{t("common:settings.customTheme.patreonText")}</p>
|
||||
<LinkButton
|
||||
to="https://www.patreon.com/sendou"
|
||||
isExternal
|
||||
size="small"
|
||||
>
|
||||
{t("common:settings.customTheme.joinPatreon")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Divider smallText>{t("common:settings.customTheme.colors")}</Divider>
|
||||
<div className={styles.themeSliders}>
|
||||
{COLOR_SLIDERS.map((slider) => (
|
||||
<ThemeSlider
|
||||
key={slider.id}
|
||||
id={slider.id}
|
||||
inputKey={slider.inputKey}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
label={t(`common:settings.customTheme.${slider.labelKey}`)}
|
||||
isHue={slider.isHue}
|
||||
value={themeInput[slider.inputKey]}
|
||||
onChange={handleSliderChange}
|
||||
/>
|
||||
))}
|
||||
{isPersonalTheme ? (
|
||||
<div className="mt-2">
|
||||
<SendouSwitch
|
||||
isSelected={chatHueEnabled}
|
||||
onChange={handleChatHueToggle}
|
||||
>
|
||||
{t("common:settings.customTheme.chatHueToggle")}
|
||||
</SendouSwitch>
|
||||
</div>
|
||||
) : null}
|
||||
{chatHueEnabled && isPersonalTheme ? (
|
||||
<div className={styles.chatColorPreview}>
|
||||
<ThemeSlider
|
||||
id="chat-hue"
|
||||
inputKey="chatHue"
|
||||
min={THEME_INPUT_LIMITS.BASE_HUE_MIN}
|
||||
max={THEME_INPUT_LIMITS.BASE_HUE_MAX}
|
||||
step={1}
|
||||
label={t("common:settings.customTheme.chatHue")}
|
||||
isHue
|
||||
value={themeInput.chatHue ?? 0}
|
||||
onChange={handleSliderChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Divider smallText>{t("common:settings.customTheme.radius")}</Divider>
|
||||
<div className={styles.themeSliders}>
|
||||
{RADIUS_SLIDERS.map((slider) => (
|
||||
<ThemeSlider
|
||||
key={slider.id}
|
||||
id={slider.id}
|
||||
inputKey={slider.inputKey}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
label={t(`common:settings.customTheme.${slider.labelKey}`)}
|
||||
value={themeInput[slider.inputKey]}
|
||||
onChange={handleSliderChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isPersonalTheme ? (
|
||||
<>
|
||||
<Divider smallText>{t("common:settings.customTheme.sizes")}</Divider>
|
||||
<div className={styles.themeSliders}>
|
||||
{SIZE_SLIDERS.map((slider) => (
|
||||
<ThemeSlider
|
||||
key={slider.id}
|
||||
id={slider.id}
|
||||
inputKey={slider.inputKey}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
label={t(`common:settings.customTheme.${slider.labelKey}`)}
|
||||
value={themeInput[slider.inputKey]}
|
||||
onChange={handleSliderChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Divider smallText>
|
||||
{t("common:settings.customTheme.borders")}
|
||||
</Divider>
|
||||
<div className={styles.themeSliders}>
|
||||
{BORDER_SLIDERS.map((slider) => (
|
||||
<ThemeSlider
|
||||
key={slider.id}
|
||||
id={slider.id}
|
||||
inputKey={slider.inputKey}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
label={t(`common:settings.customTheme.${slider.labelKey}`)}
|
||||
value={themeInput[slider.inputKey]}
|
||||
onChange={handleSliderChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<Divider />
|
||||
<ThemeShareInput
|
||||
themeInput={themeInput}
|
||||
onImport={(imported) => {
|
||||
setThemeInput(imported);
|
||||
applyThemeInput(imported);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.customThemeSelectorActions}>
|
||||
<SendouButton
|
||||
isDisabled={
|
||||
!isSupporter || (fetcherState != null && fetcherState !== "idle")
|
||||
}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
isDisabled={
|
||||
!isSupporter || (fetcherState != null && fetcherState !== "idle")
|
||||
}
|
||||
variant="destructive"
|
||||
onPress={handleReset}
|
||||
>
|
||||
{t("common:actions.reset")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeShareInput({
|
||||
themeInput,
|
||||
onImport,
|
||||
}: {
|
||||
themeInput: ThemeInput;
|
||||
onImport: (input: ThemeInput) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { copyToClipboard, copySuccess } = useCopyToClipboard();
|
||||
|
||||
const themeString = themeInputToString(themeInput);
|
||||
|
||||
const handlePaste = async () => {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const parsed = themeInputFromString(text);
|
||||
if (parsed) onImport(parsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.themeShare}>
|
||||
<Label htmlFor="theme-input">
|
||||
{t("common:settings.customTheme.shareCode")}
|
||||
</Label>
|
||||
<div className={styles.themeShareActions}>
|
||||
<input id="theme-input" type="text" value={themeString} readOnly />
|
||||
<SendouButton
|
||||
shape="square"
|
||||
variant="outlined"
|
||||
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||
onPress={() => copyToClipboard(themeString)}
|
||||
aria-label={t("common:settings.customTheme.copy")}
|
||||
/>
|
||||
<SendouButton
|
||||
shape="square"
|
||||
variant="outlined"
|
||||
icon={<PencilLine />}
|
||||
onPress={handlePaste}
|
||||
aria-label={t("common:settings.customTheme.paste")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user