From 6cf382ed0577d35280a29e1665b16208955ab2a1 Mon Sep 17 00:00:00 2001 From: hfcRed Date: Fri, 6 Mar 2026 18:02:03 +0100 Subject: [PATCH] Allow theme sharing --- app/components/CustomThemeSelector.module.css | 17 +++ app/components/CustomThemeSelector.tsx | 104 +++++++++++++++++- app/styles/common.css | 2 +- locales/en/common.json | 3 + 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/app/components/CustomThemeSelector.module.css b/app/components/CustomThemeSelector.module.css index 2a0003825..a27d9e4d6 100644 --- a/app/components/CustomThemeSelector.module.css +++ b/app/components/CustomThemeSelector.module.css @@ -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; +} diff --git a/app/components/CustomThemeSelector.tsx b/app/components/CustomThemeSelector.tsx index d3e454d83..bc3d8ae6b 100644 --- a/app/components/CustomThemeSelector.tsx +++ b/app/components/CustomThemeSelector.tsx @@ -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 = {}; + 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({ ) : null} + + { + setThemeInput(imported); + applyThemeInput(imported); + }} + />
{t("common:actions.save")} @@ -404,3 +452,57 @@ export function CustomThemeSelector({
); } + +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 ( +
+ +
+ + : } + onPress={() => copyToClipboard(themeString)} + aria-label={t("common:settings.customTheme.copy")} + /> + } + onPress={handlePaste} + aria-label={t("common:settings.customTheme.paste")} + /> +
+
+ ); +} diff --git a/app/styles/common.css b/app/styles/common.css index b4376cfa7..42cb20ee5 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -150,7 +150,7 @@ input:not(.in-container) { outline: none; } - &:focus-within { + &:focus-within:not(:read-only) { outline: var(--focus-ring); outline-offset: 1px; } diff --git a/locales/en/common.json b/locales/en/common.json index 82b21ecf7..26dce6b74 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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)",