diff --git a/apps/web/src/components/ThemeProvider.tsx b/apps/web/src/components/ThemeProvider.tsx index 331d5153..39cf8d1b 100644 --- a/apps/web/src/components/ThemeProvider.tsx +++ b/apps/web/src/components/ThemeProvider.tsx @@ -1,4 +1,18 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + DEFAULT_CUSTOM_DARK_COLORS, + DEFAULT_CUSTOM_EDITOR_THEME, + DEFAULT_CUSTOM_LIGHT_COLORS, + type CustomEditorTheme, + type ThemeColors, +} from "@/lib/custom-editor-theme"; + +export { + DEFAULT_CUSTOM_DARK_COLORS, + DEFAULT_CUSTOM_EDITOR_THEME, + DEFAULT_CUSTOM_LIGHT_COLORS, +} from "@/lib/custom-editor-theme"; +export type { CustomEditorTheme, ThemeColors } from "@/lib/custom-editor-theme"; export type ThemePreference = "system" | "light" | "dark"; export type ResolvedTheme = "light" | "dark"; @@ -84,52 +98,6 @@ export const EDITOR_THEME_NAMES = [ ] as const; export type EditorThemeName = string; -export interface ThemeColors { - background: string; - text: string; - muted: string; - heading: string; - accent: string; - soft: string; - border: string; -} - -export interface CustomEditorTheme { - id: string; - name: string; - light: ThemeColors; - dark: ThemeColors; - customCss?: string; -} - -export const DEFAULT_CUSTOM_LIGHT_COLORS: ThemeColors = { - background: "#fffdf7", - text: "#292524", - muted: "#57534e", - heading: "#1c1917", - accent: "#0f766e", - soft: "#f0fdfa", - border: "#99f6e4", -}; - -export const DEFAULT_CUSTOM_DARK_COLORS: ThemeColors = { - background: "#1c1917", - text: "#fafaf9", - muted: "#d6d3d1", - heading: "#fafaf9", - accent: "#2dd4bf", - soft: "#292524", - border: "#44403c", -}; - -export const DEFAULT_CUSTOM_EDITOR_THEME: CustomEditorTheme = { - id: "custom-default", - name: "My custom theme", - light: DEFAULT_CUSTOM_LIGHT_COLORS, - dark: DEFAULT_CUSTOM_DARK_COLORS, - customCss: "", -}; - interface AppearanceThemeContextValue { preference: ThemePreference; resolvedTheme: ResolvedTheme; diff --git a/apps/web/src/components/settings/CustomEditorThemeDialog.tsx b/apps/web/src/components/settings/CustomEditorThemeDialog.tsx index 41a1c347..e381fa53 100644 --- a/apps/web/src/components/settings/CustomEditorThemeDialog.tsx +++ b/apps/web/src/components/settings/CustomEditorThemeDialog.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { RotateCcw, Sun, Moon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Download, Moon, RotateCcw, Sun, Upload } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -12,13 +12,19 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { sanitizeAndScopeCss } from "@/lib/css-sandbox"; -import { contrastRatio } from "@/lib/color-contrast"; import { + CustomEditorThemeFileError, DEFAULT_CUSTOM_LIGHT_COLORS, DEFAULT_CUSTOM_DARK_COLORS, + MAX_CUSTOM_EDITOR_THEME_FILE_BYTES, + customEditorThemeFileName, + getEditorThemeContrastIssues, + isValidThemeColors, + parseCustomEditorThemeFile, + serializeCustomEditorTheme, type CustomEditorTheme, type ThemeColors, -} from "../ThemeProvider"; +} from "@/lib/custom-editor-theme"; interface CustomEditorThemeDialogProps { open: boolean; @@ -39,12 +45,6 @@ const COLOR_FIELDS = [ ["border", "settings.customEditorTheme.border"], ] as const; -const hasAccessibleContrast = (colors: ThemeColors) => - contrastRatio(colors.text, colors.background) >= 4.5 && - contrastRatio(colors.muted, colors.soft) >= 4.5 && - contrastRatio(colors.heading, colors.background) >= 4.5 && - contrastRatio(colors.accent, colors.background) >= 3; - export const CustomEditorThemeDialog = ({ open, theme, @@ -53,14 +53,17 @@ export const CustomEditorThemeDialog = ({ onDelete, isDefaultTheme = false, }: CustomEditorThemeDialogProps) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const fileInputRef = useRef(null); const [draft, setDraft] = useState(theme); const [activeMode, setActiveMode] = useState<"light" | "dark">("light"); + const [importError, setImportError] = useState(null); useEffect(() => { if (open) { setDraft(theme); setActiveMode("light"); + setImportError(null); } }, [open, theme]); @@ -79,15 +82,48 @@ export const CustomEditorThemeDialog = ({ const activeColors = draft[activeMode] || DEFAULT_CUSTOM_LIGHT_COLORS; const defaultColors = activeMode === "light" ? DEFAULT_CUSTOM_LIGHT_COLORS : DEFAULT_CUSTOM_DARK_COLORS; - const validColors = (colors: ThemeColors) => - COLOR_FIELDS.every(([key]) => /^#[0-9a-f]{6}$/i.test(colors[key])); - const valid = draft.name.trim().length > 0 && - validColors(draft.light) && - validColors(draft.dark) && - hasAccessibleContrast(draft.light) && - hasAccessibleContrast(draft.dark); + isValidThemeColors(draft.light) && + isValidThemeColors(draft.dark); + const lightContrastIssues = isValidThemeColors(draft.light) ? getEditorThemeContrastIssues(draft.light) : []; + const darkContrastIssues = isValidThemeColors(draft.dark) ? getEditorThemeContrastIssues(draft.dark) : []; + const activeContrastIssues = activeMode === "light" ? lightContrastIssues : darkContrastIssues; + + const describeImportError = (error: unknown) => { + if (error instanceof CustomEditorThemeFileError) { + return t(`settings.customEditorTheme.importErrors.${error.code}`); + } + return t("settings.customEditorTheme.importErrors.invalidFile"); + }; + + const handleImport = async (file: File | undefined) => { + if (!file) return; + setImportError(null); + try { + if (file.size > MAX_CUSTOM_EDITOR_THEME_FILE_BYTES) { + throw new CustomEditorThemeFileError("fileTooLarge", "Editor theme file is too large."); + } + const imported = parseCustomEditorThemeFile(await file.text()); + setDraft({ id: `custom-${crypto.randomUUID()}`, ...imported }); + setActiveMode("light"); + } catch (error) { + setImportError(describeImportError(error)); + } finally { + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const handleExport = () => { + const url = URL.createObjectURL(new Blob([serializeCustomEditorTheme(draft)], { type: "application/json;charset=utf-8" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = customEditorThemeFileName(draft.name); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + }; const handleReset = () => { setDraft((current) => ({ @@ -168,11 +204,16 @@ export const CustomEditorThemeDialog = ({ ))} - {!valid && validColors(draft.light) && validColors(draft.dark) ? ( + {activeContrastIssues.length > 0 ? (

- {t("settings.customEditorTheme.contrastWarning")} + {t("settings.customEditorTheme.contrastWarning", { + mode: t(`settings.customEditorTheme.modes.${activeMode}`), + fields: new Intl.ListFormat(i18n.resolvedLanguage ?? i18n.language, { type: "conjunction" }) + .format(activeContrastIssues.map((field) => t(`settings.customEditorTheme.${field}`))), + })}

) : null} + {importError ?

{importError}

: null} {/* Custom CSS Textarea */}