mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-05 20:56:13 -05:00
Allow theme sharing
This commit is contained in:
parent
596fc6b251
commit
6cf382ed05
|
|
@ -84,3 +84,20 @@
|
|||
.chatColorPreview {
|
||||
color: oklch(from var(--color-text-accent) l c var(--_chat-h));
|
||||
}
|
||||
|
||||
.themeShare {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.themeShareActions {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.themeShareInput input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { Check, Clipboard, PencilLine } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
import {
|
||||
CUSTOM_THEME_VARS,
|
||||
type CustomTheme,
|
||||
|
|
@ -11,7 +13,7 @@ import {
|
|||
clampThemeToGamut,
|
||||
type ThemeInput,
|
||||
} from "~/utils/oklch-gamut";
|
||||
import { THEME_INPUT_LIMITS } from "~/utils/zod";
|
||||
import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/zod";
|
||||
import styles from "./CustomThemeSelector.module.css";
|
||||
import { Divider } from "./Divider";
|
||||
import { LinkButton, SendouButton } from "./elements/Button";
|
||||
|
|
@ -129,6 +131,44 @@ type ThemeInputKey =
|
|||
| (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;
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_INPUT: ThemeInput = {
|
||||
baseHue: 260,
|
||||
baseChroma: 0.012,
|
||||
|
|
@ -389,6 +429,14 @@ export function CustomThemeSelector({
|
|||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<Divider />
|
||||
<ThemeShareInput
|
||||
themeInput={themeInput}
|
||||
onImport={(imported) => {
|
||||
setThemeInput(imported);
|
||||
applyThemeInput(imported);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.customThemeSelectorActions}>
|
||||
<SendouButton isDisabled={!isSupporter} onPress={handleSave}>
|
||||
{t("common:actions.save")}
|
||||
|
|
@ -404,3 +452,57 @@ export function CustomThemeSelector({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeShareInput({
|
||||
themeInput,
|
||||
onImport,
|
||||
}: {
|
||||
themeInput: ThemeInput;
|
||||
onImport: (input: ThemeInput) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [state, copyToClipboard] = useCopyToClipboard();
|
||||
const [copySuccess, setCopySuccess] = React.useState(false);
|
||||
|
||||
const themeString = themeInputToString(themeInput);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!state.value) return;
|
||||
|
||||
setCopySuccess(true);
|
||||
const timeout = setTimeout(() => setCopySuccess(false), 2000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [state]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ input:not(.in-container) {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
&:focus-within:not(:read-only) {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -342,6 +342,9 @@
|
|||
"settings.customTheme.selectors": "Selectors",
|
||||
"settings.customTheme.spacings": "Spacings",
|
||||
"settings.customTheme.borderWidth": "Border width",
|
||||
"settings.customTheme.shareCode": "Share code",
|
||||
"settings.customTheme.copy": "Copy theme code",
|
||||
"settings.customTheme.paste": "Paste theme code",
|
||||
"settings.customTheme.patreonText": "Become a Patreon supporter to customize themes!",
|
||||
"settings.customTheme.joinPatreon": "Join Patreon",
|
||||
"clockFormat.auto": "Auto (use language default)",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user