diff --git a/components/CustomBranding.tsx b/components/CustomBranding.tsx index 75acc79c79..cb90a1d336 100644 --- a/components/CustomBranding.tsx +++ b/components/CustomBranding.tsx @@ -1,5 +1,158 @@ import { useEffect } from "react"; +const brandColor = "#292929"; +const brandTextColor = "#ffffff"; + +export function colorNameToHex(color: string) { + const colors = { + aliceblue: "#f0f8ff", + antiquewhite: "#faebd7", + aqua: "#00ffff", + aquamarine: "#7fffd4", + azure: "#f0ffff", + beige: "#f5f5dc", + bisque: "#ffe4c4", + black: "#000000", + blanchedalmond: "#ffebcd", + blue: "#0000ff", + blueviolet: "#8a2be2", + brown: "#a52a2a", + burlywood: "#deb887", + cadetblue: "#5f9ea0", + chartreuse: "#7fff00", + chocolate: "#d2691e", + coral: "#ff7f50", + cornflowerblue: "#6495ed", + cornsilk: "#fff8dc", + crimson: "#dc143c", + cyan: "#00ffff", + darkblue: "#00008b", + darkcyan: "#008b8b", + darkgoldenrod: "#b8860b", + darkgray: "#a9a9a9", + darkgreen: "#006400", + darkkhaki: "#bdb76b", + darkmagenta: "#8b008b", + darkolivegreen: "#556b2f", + darkorange: "#ff8c00", + darkorchid: "#9932cc", + darkred: "#8b0000", + darksalmon: "#e9967a", + darkseagreen: "#8fbc8f", + darkslateblue: "#483d8b", + darkslategray: "#2f4f4f", + darkturquoise: "#00ced1", + darkviolet: "#9400d3", + deeppink: "#ff1493", + deepskyblue: "#00bfff", + dimgray: "#696969", + dodgerblue: "#1e90ff", + firebrick: "#b22222", + floralwhite: "#fffaf0", + forestgreen: "#228b22", + fuchsia: "#ff00ff", + gainsboro: "#dcdcdc", + ghostwhite: "#f8f8ff", + gold: "#ffd700", + goldenrod: "#daa520", + gray: "#808080", + green: "#008000", + greenyellow: "#adff2f", + honeydew: "#f0fff0", + hotpink: "#ff69b4", + "indianred ": "#cd5c5c", + indigo: "#4b0082", + ivory: "#fffff0", + khaki: "#f0e68c", + lavender: "#e6e6fa", + lavenderblush: "#fff0f5", + lawngreen: "#7cfc00", + lemonchiffon: "#fffacd", + lightblue: "#add8e6", + lightcoral: "#f08080", + lightcyan: "#e0ffff", + lightgoldenrodyellow: "#fafad2", + lightgrey: "#d3d3d3", + lightgreen: "#90ee90", + lightpink: "#ffb6c1", + lightsalmon: "#ffa07a", + lightseagreen: "#20b2aa", + lightskyblue: "#87cefa", + lightslategray: "#778899", + lightsteelblue: "#b0c4de", + lightyellow: "#ffffe0", + lime: "#00ff00", + limegreen: "#32cd32", + linen: "#faf0e6", + magenta: "#ff00ff", + maroon: "#800000", + mediumaquamarine: "#66cdaa", + mediumblue: "#0000cd", + mediumorchid: "#ba55d3", + mediumpurple: "#9370d8", + mediumseagreen: "#3cb371", + mediumslateblue: "#7b68ee", + mediumspringgreen: "#00fa9a", + mediumturquoise: "#48d1cc", + mediumvioletred: "#c71585", + midnightblue: "#191970", + mintcream: "#f5fffa", + mistyrose: "#ffe4e1", + moccasin: "#ffe4b5", + navajowhite: "#ffdead", + navy: "#000080", + oldlace: "#fdf5e6", + olive: "#808000", + olivedrab: "#6b8e23", + orange: "#ffa500", + orangered: "#ff4500", + orchid: "#da70d6", + palegoldenrod: "#eee8aa", + palegreen: "#98fb98", + paleturquoise: "#afeeee", + palevioletred: "#d87093", + papayawhip: "#ffefd5", + peachpuff: "#ffdab9", + peru: "#cd853f", + pink: "#ffc0cb", + plum: "#dda0dd", + powderblue: "#b0e0e6", + purple: "#800080", + rebeccapurple: "#663399", + red: "#ff0000", + rosybrown: "#bc8f8f", + royalblue: "#4169e1", + saddlebrown: "#8b4513", + salmon: "#fa8072", + sandybrown: "#f4a460", + seagreen: "#2e8b57", + seashell: "#fff5ee", + sienna: "#a0522d", + silver: "#c0c0c0", + skyblue: "#87ceeb", + slateblue: "#6a5acd", + slategray: "#708090", + snow: "#fffafa", + springgreen: "#00ff7f", + steelblue: "#4682b4", + tan: "#d2b48c", + teal: "#008080", + thistle: "#d8bfd8", + tomato: "#ff6347", + turquoise: "#40e0d0", + violet: "#ee82ee", + wheat: "#f5deb3", + white: "#ffffff", + whitesmoke: "#f5f5f5", + yellow: "#ffff00", + yellowgreen: "#9acd32", + }; + + return colors[color.toLowerCase() as keyof typeof colors] !== undefined + ? colors[color.toLowerCase() as keyof typeof colors] + : false; +} + function computeContrastRatio(a: number[], b: number[]) { const lum1 = computeLuminance(a[0], a[1], a[2]); const lum2 = computeLuminance(b[0], b[1], b[2]); @@ -22,14 +175,30 @@ function hexToRGB(hex: string) { } function getContrastingTextColor(bgColor: string | null): string { - bgColor = bgColor == "" || bgColor == null ? "#292929" : bgColor; + bgColor = bgColor == "" || bgColor == null ? brandColor : bgColor; const rgb = hexToRGB(bgColor); const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]); const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929 - return whiteContrastRatio > blackContrastRatio ? "#ffffff" : "#292929"; + return whiteContrastRatio > blackContrastRatio ? brandTextColor : brandColor; } -const BrandColor = ({ val = "#292929" }: { val: string | undefined | null }) => { +export function isValidHexCode(val: string | null) { + if (val) { + val = val.indexOf("#") === 0 ? val : "#" + val; + const regex = new RegExp("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"); + return regex.test(val); + } + return false; +} + +export function fallBackHex(val: string | null): string { + if (val) if (colorNameToHex(val)) return colorNameToHex(val) as string; + return brandColor; +} + +const BrandColor = ({ val = brandColor }: { val: string | undefined | null }) => { + // ensure acceptable hex-code + val = isValidHexCode(val) ? (val?.indexOf("#") === 0 ? val : "#" + val) : fallBackHex(val); useEffect(() => { document.documentElement.style.setProperty("--brand-color", val); document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(val)); diff --git a/components/Swatch.tsx b/components/Swatch.tsx new file mode 100644 index 0000000000..89a49af86c --- /dev/null +++ b/components/Swatch.tsx @@ -0,0 +1,26 @@ +import classNames from "@lib/classNames"; + +export type SwatchProps = { + size?: "base" | "sm" | "lg"; + backgroundColor: string; + onClick: () => void; +}; + +const Swatch = (props: SwatchProps) => { + const { size, backgroundColor, onClick } = props; + return ( +
+
+
+ ); +}; + +export default Swatch; diff --git a/components/ui/colorpicker.tsx b/components/ui/colorpicker.tsx new file mode 100644 index 0000000000..b00e96790b --- /dev/null +++ b/components/ui/colorpicker.tsx @@ -0,0 +1,100 @@ +import { useCallback, useRef, useState } from "react"; +import { useEffect } from "react"; +import { HexColorInput, HexColorPicker } from "react-colorful"; + +import { isValidHexCode, fallBackHex } from "@components/CustomBranding"; +import Swatch from "@components/Swatch"; + +type Handler = (event: MouseEvent | Event) => void; +function useEventListener< + KW extends keyof WindowEventMap, + KH extends keyof HTMLElementEventMap, + T extends HTMLElement | void = void +>( + eventName: KW | KH, + handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | Event) => void, + element?: React.RefObject +) { + // Create a ref that stores handler + const savedHandler = useRef(); + useEffect(() => { + // Define the listening target + const targetElement: T | Window = element?.current || window; + if (!(targetElement && targetElement.addEventListener)) { + return; + } + // Update saved handler if necessary + if (savedHandler.current !== handler) { + savedHandler.current = handler; + } + // Create event listener that calls handler function stored in ref + const eventListener: typeof handler = (event) => { + // eslint-disable-next-line no-extra-boolean-cast + if (!!savedHandler?.current) { + savedHandler.current(event); + } + }; + targetElement.addEventListener(eventName, eventListener); + // Remove event listener on cleanup + return () => { + targetElement.removeEventListener(eventName, eventListener); + }; + }, [eventName, element, handler]); +} + +function useOnClickOutside( + ref: React.RefObject, + handler: Handler, + mouseEvent: "mousedown" | "mouseup" = "mousedown" +): void { + useEventListener(mouseEvent, (event) => { + const el = ref?.current; + // Do nothing if clicking ref's element or descendent elements + if (!el || el.contains(event.target as Node)) { + return; + } + handler(event); + }); +} +export type ColorPickerProps = { + defaultValue: string; + onChange: (text: string) => void; +}; + +const ColorPicker = (props: ColorPickerProps) => { + const init = !isValidHexCode(props.defaultValue) ? fallBackHex(props.defaultValue) : props.defaultValue; + const [color, setColor] = useState(init); + const [isOpen, toggle] = useState(false); + const popover = useRef() as React.MutableRefObject; + const close = useCallback(() => toggle(false), []); + useOnClickOutside(popover, close); + return ( +
+ toggle(!isOpen)} /> + + {isOpen && ( +
+ { + setColor(val); + props.onChange(val); + }} + /> +
+ )} + { + setColor(val); + props.onChange(val); + }} + type="text" + /> +
+ ); +}; + +export default ColorPicker; diff --git a/package.json b/package.json index e9a994d6bd..603018892f 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "otplib": "^12.0.1", "qrcode": "^1.5.0", "react": "^17.0.2", + "react-colorful": "^5.5.1", "react-date-picker": "^8.3.6", "react-digit-input": "^2.1.0", "react-dom": "^17.0.2", @@ -169,4 +170,4 @@ "prisma": { "seed": "ts-node ./prisma/seed.ts" } -} \ No newline at end of file +} diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index 008dd8baec..72b19ae4d9 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -30,6 +30,7 @@ import { Alert } from "@components/ui/Alert"; import Avatar from "@components/ui/Avatar"; import Badge from "@components/ui/Badge"; import Button from "@components/ui/Button"; +import ColorPicker from "@components/ui/colorpicker"; type Props = inferSSRProps; @@ -151,7 +152,6 @@ function SettingsView(props: ComponentProps & { localeProp: str const emailRef = useRef(null!); const descriptionRef = useRef(null!); const avatarRef = useRef(null!); - const brandColorRef = useRef(null!); const hideBrandingRef = useRef(null!); const [selectedTheme, setSelectedTheme] = useState(); const [selectedTimeZone, setSelectedTimeZone] = useState(props.user.timeZone); @@ -167,6 +167,7 @@ function SettingsView(props: ComponentProps & { localeProp: str const [imageSrc, setImageSrc] = useState(props.user.avatar || ""); const [hasErrors, setHasErrors] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + const [brandColor, setBrandColor] = useState(props.user.brandColor); useEffect(() => { if (!props.user.theme) return; @@ -184,7 +185,7 @@ function SettingsView(props: ComponentProps & { localeProp: str const enteredEmail = emailRef.current.value; const enteredDescription = descriptionRef.current.value; const enteredAvatar = avatarRef.current.value; - const enteredBrandColor = brandColorRef.current.value; + const enteredBrandColor = brandColor; const enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value; const enteredWeekStartDay = selectedWeekStartDay.value; const enteredHideBranding = hideBrandingRef.current.checked; @@ -402,17 +403,7 @@ function SettingsView(props: ComponentProps & { localeProp: str -
- -
+
diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index 5546494e72..32046212a3 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -601,6 +601,7 @@ const loggedInViewerRouter = createProtectedRouter() input: z.object({ username: z.string().optional(), name: z.string().optional(), + email: z.string().optional(), bio: z.string().optional(), avatar: z.string().optional(), timeZone: z.string().optional(), diff --git a/yarn.lock b/yarn.lock index 151dbd9194..4a8715ec01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9603,6 +9603,11 @@ react-calendar@^3.3.1: merge-class-names "^1.1.1" prop-types "^15.6.0" +react-colorful@^5.5.1: + version "5.5.1" + resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784" + integrity sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg== + react-date-picker@^8.3.3: version "8.3.5" resolved "https://registry.yarnpkg.com/react-date-picker/-/react-date-picker-8.3.5.tgz#3972db3fc1f37cfd6b09c09bf4988817b4d2be72"