From a7ab8ee12372fe7b7152d098147f0effb29a9b12 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 1 May 2026 02:29:39 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D1=8C=20=D1=86=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=B4=D0=BE=D0=BF=D0=B8=D0=BB=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=80=D1=8B=20=D0=B8=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plane-src/apps/web/app/root.tsx | 6 +- .../projects/external-contours/board-item.tsx | 10 +- .../shared/nodedc-work-item-card.tsx | 4 +- .../core/components/presence/presence-dot.tsx | 2 +- .../pages/preferences/accent-color.tsx | 276 +-------- .../pages/preferences/default-list.tsx | 3 + .../pages/preferences/passive-card-color.tsx | 573 ++++++++++++++++++ .../apps/web/core/helpers/nodedc-design.ts | 3 + .../web/core/lib/wrappers/store-wrapper.tsx | 26 +- .../apps/web/core/store/user/profile.store.ts | 2 + plane-src/apps/web/styles/globals.css | 56 +- .../types/src/current-user/profile.ts | 2 + plane-src/packages/types/src/users.ts | 4 + plane-src/packages/utils/src/theme/index.ts | 2 + .../packages/utils/src/theme/nodedc-accent.ts | 30 + 15 files changed, 728 insertions(+), 271 deletions(-) create mode 100644 plane-src/apps/web/core/components/settings/profile/content/pages/preferences/passive-card-color.tsx diff --git a/plane-src/apps/web/app/root.tsx b/plane-src/apps/web/app/root.tsx index 8b4c01e..ebae965 100644 --- a/plane-src/apps/web/app/root.tsx +++ b/plane-src/apps/web/app/root.tsx @@ -54,7 +54,8 @@ const toRelativeLuminance = (rgb: readonly number[]) => { return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; -const getReadableTextRgb = (rgb: readonly number[]) => (toRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB); +const getReadableTextRgb = (rgb: readonly number[]) => + toRelativeLuminance(rgb) > 0.52 ? DARK_TEXT_RGB : LIGHT_TEXT_RGB; const accentRgb = designConfig.nodedc.accent_rgb as [number, number, number]; const activeCardRgb = designConfig.nodedc.active_card_rgb as [number, number, number]; @@ -68,10 +69,13 @@ const onPassiveCardRgb = getReadableTextRgb(passiveCardRgb); const designConfigStyle = { "--nodedc-accent-rgb": formatRgbTuple(accentRgb), "--nodedc-card-passive-rgb": formatRgbTuple(passiveCardRgb), + "--nodedc-card-passive-surface-rgb": formatRgbTuple(passiveCardRgb), + "--nodedc-presence-dot-border-rgb": formatRgbTuple(passiveCardRgb), "--nodedc-card-active-rgb": formatRgbTuple(activeCardRgb), "--nodedc-on-accent-rgb": formatRgbTuple(onAccentRgb), "--nodedc-on-card-active-rgb": formatRgbTuple(onActiveCardRgb), "--nodedc-on-card-passive-rgb": formatRgbTuple(onPassiveCardRgb), + "--nodedc-on-card-passive-surface-rgb": formatRgbTuple(onPassiveCardRgb), "--brand-default": formatCssRgb(accentRgb), "--brand-300": formatCssRgb(blendRgb(accentRgb, 255, 0.35)), "--brand-700": formatCssRgb(blendRgb(accentRgb, 0, 0.25)), diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx index 80a29cd..53e9842 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx @@ -128,11 +128,13 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard const stateOptions = canEditTargetIssue ? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state) : sourceStateIds.map((stateId) => sourceStateMap[stateId]).filter((state): state is IState => !!state); - const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"; + const foregroundClasses = isActive + ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" + : "text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]"; const subtleTextClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]"; const pillBackgroundClasses = isActive ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" - : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; + : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-[rgb(var(--nodedc-on-card-passive-rgb))]"; const cornerActionButtonClasses = cn( "flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none", isActive @@ -141,7 +143,9 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard ); const assigneeButtonClasses = cn( "flex h-7 min-w-7 items-center justify-center rounded-full border-0 bg-transparent p-0 shadow-none outline-none transition-colors", - isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white" + isActive + ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" + : "text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]" ); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); const checkerBlocksTotal = issue.checker_blocks_count ?? 0; diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx index 76ddc09..ae756d0 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx @@ -26,10 +26,10 @@ type TNodedcWorkItemCardProps = { export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({ surfaceClassName: isActive ? "bg-[rgb(var(--nodedc-card-active-rgb))] text-[rgb(var(--nodedc-on-card-active-rgb))]" - : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white", + : "bg-[rgb(var(--nodedc-card-passive-surface-rgb))] text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]", foregroundClasses: isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" - : "text-[rgb(var(--nodedc-on-card-passive-rgb))]", + : "text-[rgb(var(--nodedc-on-card-passive-surface-rgb))]", subtleTextClasses: isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]", pillBackgroundClasses: isActive ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" diff --git a/plane-src/apps/web/core/components/presence/presence-dot.tsx b/plane-src/apps/web/core/components/presence/presence-dot.tsx index 2099504..b558232 100644 --- a/plane-src/apps/web/core/components/presence/presence-dot.tsx +++ b/plane-src/apps/web/core/components/presence/presence-dot.tsx @@ -17,7 +17,7 @@ export const PresenceDot = (props: Props) => { diff --git a/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/accent-color.tsx b/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/accent-color.tsx index ba8e01e..f033caa 100644 --- a/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/accent-color.tsx +++ b/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/accent-color.tsx @@ -4,25 +4,12 @@ * See the LICENSE file for details. */ -import { Fragment, useEffect, useMemo, useState } from "react"; -import { Popover, Transition } from "@headlessui/react"; -import { observer } from "mobx-react"; -import * as ColorPicker from "react-color"; -import type { ColorResult } from "react-color"; // plane imports -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import { - applyNodedcAccent, - getReadableNodedcTextRgb, - nodedcAccentHexToRgb, - normalizeNodedcAccentHex, -} from "@plane/utils"; +import { applyNodedcAccent } from "@plane/utils"; // helpers -import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design"; -// components -import { SettingsControlItem } from "@/components/settings/control-item"; -// hooks -import { useUserProfile } from "@/hooks/store/user"; +import { NODEDC_DEFAULT_ACCENT_HEX } from "../../../../../../helpers/nodedc-design"; +// local imports +import { ProfileSettingsNodedcColorControl } from "./passive-card-color"; const ACCENT_PRESET_COLORS = [ "#EF4444", @@ -42,242 +29,19 @@ const ACCENT_PRESET_COLORS = [ "#F5F7FB", ]; -const CHROME_PICKER_STYLES = { - default: { - picker: { - width: "100%", - background: "transparent", - borderRadius: 0, - boxShadow: "none", - fontFamily: "inherit", - }, - saturation: { - borderRadius: "1.35rem", - overflow: "hidden", - }, - Saturation: { - borderRadius: "1.35rem", - }, - body: { - padding: "0.9rem 0 0", - }, - controls: { - alignItems: "center", - gap: "0.75rem", - }, - color: { - width: "2rem", - }, - swatch: { - borderRadius: "999px", - boxShadow: "0 0 0 1px rgba(255,255,255,0.14)", - }, - hue: { - borderRadius: "999px", - overflow: "hidden", - }, - Hue: { - borderRadius: "999px", - }, - alpha: { - display: "none", - }, - Alpha: { - display: "none", - }, - }, -}; - -const getReadableColor = (hex: string) => { - const rgb = nodedcAccentHexToRgb(hex); - if (!rgb) return undefined; - return `rgb(${getReadableNodedcTextRgb(rgb).join(" ")})`; -}; - -export const ProfileSettingsAccentColor = observer(function ProfileSettingsAccentColor() { - const { data: userProfile, updateUserTheme } = useUserProfile(); - const [draftAccent, setDraftAccent] = useState(NODEDC_DEFAULT_ACCENT_HEX); - const [isSaving, setIsSaving] = useState(false); - - const savedAccent = useMemo( - () => normalizeNodedcAccentHex(userProfile?.theme?.nodedcAccent) || NODEDC_DEFAULT_ACCENT_HEX, - [userProfile?.theme?.nodedcAccent] - ); - const normalizedDraftAccent = normalizeNodedcAccentHex(draftAccent); - const isDraftValid = !!normalizedDraftAccent; - const isDirty = normalizedDraftAccent !== savedAccent; - - useEffect(() => { - setDraftAccent(savedAccent); - }, [savedAccent]); - - const handleAccentChange = (value: string) => { - const nextValue = value.startsWith("#") ? value : `#${value}`; - setDraftAccent(nextValue); - - const normalizedValue = normalizeNodedcAccentHex(nextValue); - if (normalizedValue) applyNodedcAccent(normalizedValue); - }; - - const handleColorPickerChange = (color: ColorResult) => { - handleAccentChange(color.hex); - }; - - const handleSave = async () => { - if (!normalizedDraftAccent) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Ошибка", - message: "Введите корректный HEX-цвет.", - }); - return; - } - - try { - setIsSaving(true); - applyNodedcAccent(normalizedDraftAccent); - await updateUserTheme({ nodedcAccent: normalizedDraftAccent }); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Сохранено", - message: "Акцентный цвет обновлен.", - }); - } catch (_error) { - applyNodedcAccent(savedAccent); - setDraftAccent(savedAccent); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Ошибка", - message: "Не удалось сохранить акцентный цвет.", - }); - } finally { - setIsSaving(false); - } - }; - - const handleReset = async () => { - try { - setIsSaving(true); - setDraftAccent(NODEDC_DEFAULT_ACCENT_HEX); - applyNodedcAccent(NODEDC_DEFAULT_ACCENT_HEX); - await updateUserTheme({ nodedcAccent: undefined }); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Сброшено", - message: "Акцентный цвет возвращен к дизайн-конфигу.", - }); - } catch (_error) { - applyNodedcAccent(savedAccent); - setDraftAccent(savedAccent); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Ошибка", - message: "Не удалось сбросить акцентный цвет.", - }); - } finally { - setIsSaving(false); - } - }; - - return ( - -
- - {() => ( - <> - - - - - - -
- {ACCENT_PRESET_COLORS.map((color) => { - const isSelected = color === normalizedDraftAccent; - - return ( - - ); - })} -
-
-
- - )} -
- handleAccentChange(event.target.value)} - placeholder={NODEDC_DEFAULT_ACCENT_HEX} - className="nodedc-settings-input h-11 min-h-11 w-full pl-10 pr-4 text-13 font-medium uppercase" - style={{ - color: normalizedDraftAccent ? getReadableColor(normalizedDraftAccent) : undefined, - }} - aria-invalid={!isDraftValid} - /> -
-
- - -
- - } - /> - ); -}); +export const ProfileSettingsAccentColor = () => ( + +); diff --git a/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx b/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx index 4146860..4fe284d 100644 --- a/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx +++ b/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/default-list.tsx @@ -9,6 +9,7 @@ import { observer } from "mobx-react"; import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher"; // local imports import { ProfileSettingsAccentColor } from "./accent-color"; +import { ProfileSettingsPassiveCardColor, ProfileSettingsPassiveCardSurfaceColor } from "./passive-card-color"; import { ProfileSettingsToolbarLayout } from "./toolbar-layout"; export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() { @@ -22,6 +23,8 @@ export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSe }} /> + + ); diff --git a/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/passive-card-color.tsx b/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/passive-card-color.tsx new file mode 100644 index 0000000..e03619e --- /dev/null +++ b/plane-src/apps/web/core/components/settings/profile/content/pages/preferences/passive-card-color.tsx @@ -0,0 +1,573 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { BookmarkPlus, Pipette, Trash2 } from "lucide-react"; +import { observer } from "mobx-react"; +import * as ColorPicker from "react-color"; +import type { ColorResult } from "react-color"; +// plane imports +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { + applyNodedcPassiveCardColor, + applyNodedcPassiveCardSurfaceColor, + getReadableNodedcTextRgb, + nodedcAccentHexToRgb, + cn, + normalizeNodedcAccentHex, +} from "@plane/utils"; +// helpers +import { + NODEDC_DEFAULT_PASSIVE_CARD_HEX, + NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX, +} from "../../../../../../helpers/nodedc-design"; +// components +import { SettingsControlItem } from "@/components/settings/control-item"; +// hooks +import { useUserProfile } from "@/hooks/store/user"; + +const PASSIVE_CARD_PRESET_COLORS = [ + "#050505", + "#101113", + "#1A1B1E", + "#242529", + "#2A2B2E", + "#34363B", + "#42454B", + "#5A5F67", + "#1F2937", + "#243447", + "#312E81", + "#3B1D4A", + "#3F1D2F", + "#3C2F1D", + "#4B5563", + "#7C7F85", +]; + +const COLOR_TEMPLATE_STORAGE_KEY = "nodedc:color-template-swatches:v1"; +const COLOR_TEMPLATE_LIMIT = 24; +const COLOR_PICKER_PANEL_WIDTH = 336; +const COLOR_PICKER_PANEL_ESTIMATED_HEIGHT = 430; +const COLOR_PICKER_PANEL_GAP = 12; +const COLOR_PICKER_VIEWPORT_MARGIN = 16; + +type TEyeDropperResult = { + sRGBHex: string; +}; + +type TEyeDropperConstructor = new () => { + open: () => Promise; +}; + +declare global { + interface Window { + EyeDropper?: TEyeDropperConstructor; + } +} + +const clampNumber = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const normalizeTemplateColorList = (colors: unknown[]): string[] => + Array.from( + new Set( + colors + .map((color) => (typeof color === "string" ? normalizeNodedcAccentHex(color) : undefined)) + .filter((color): color is string => !!color) + ) + ).slice(0, COLOR_TEMPLATE_LIMIT); + +const readTemplateColors = (): string[] => { + if (typeof window === "undefined") return []; + + try { + const parsedValue = JSON.parse(window.localStorage.getItem(COLOR_TEMPLATE_STORAGE_KEY) || "[]"); + return Array.isArray(parsedValue) ? normalizeTemplateColorList(parsedValue) : []; + } catch (_error) { + return []; + } +}; + +const writeTemplateColors = (colors: string[]) => { + if (typeof window === "undefined") return; + window.localStorage.setItem(COLOR_TEMPLATE_STORAGE_KEY, JSON.stringify(normalizeTemplateColorList(colors))); +}; + +const CHROME_PICKER_STYLES = { + default: { + picker: { + width: "100%", + background: "transparent", + borderRadius: 0, + boxShadow: "none", + fontFamily: "inherit", + }, + saturation: { + borderRadius: "1.35rem", + overflow: "hidden", + }, + Saturation: { + borderRadius: "1.35rem", + }, + body: { + padding: "0.9rem 0 0", + }, + controls: { + alignItems: "center", + gap: "0.75rem", + }, + color: { + width: "2rem", + }, + swatch: { + borderRadius: "999px", + boxShadow: "0 0 0 1px rgba(255,255,255,0.14)", + }, + hue: { + borderRadius: "999px", + overflow: "hidden", + }, + Hue: { + borderRadius: "999px", + }, + alpha: { + display: "none", + }, + Alpha: { + display: "none", + }, + }, +}; + +const getReadableColor = (hex: string) => { + const rgb = nodedcAccentHexToRgb(hex); + if (!rgb) return undefined; + return `rgb(${getReadableNodedcTextRgb(rgb).join(" ")})`; +}; + +type TNodedcColorThemeKey = "nodedcAccent" | "nodedcPassiveCard" | "nodedcPassiveCardSurface"; + +type TProfileSettingsNodedcColorControlProps = { + themeKey: TNodedcColorThemeKey; + defaultColor: string; + title: string; + description: string; + inputName: string; + paletteLabel: string; + saveSuccessMessage: string; + saveErrorMessage: string; + resetSuccessMessage: string; + resetErrorMessage: string; + presetColors: string[]; + applyColor: (hex: string | null | undefined) => boolean; +}; + +export const ProfileSettingsNodedcColorControl = observer(function ProfileSettingsNodedcColorControl( + props: TProfileSettingsNodedcColorControlProps +) { + const { + themeKey, + defaultColor, + title, + description, + inputName, + paletteLabel, + saveSuccessMessage, + saveErrorMessage, + resetSuccessMessage, + resetErrorMessage, + presetColors, + applyColor, + } = props; + const { data: userProfile, updateUserTheme } = useUserProfile(); + const [draftColor, setDraftColor] = useState(defaultColor); + const [isBrowser, setIsBrowser] = useState(false); + const [isPaletteOpen, setIsPaletteOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [panelPosition, setPanelPosition] = useState({ left: 0, top: 0 }); + const [templateColors, setTemplateColors] = useState([]); + const paletteButtonRef = useRef(null); + const palettePanelRef = useRef(null); + + const savedColor = useMemo( + () => normalizeNodedcAccentHex(userProfile?.theme?.[themeKey]) || defaultColor, + [defaultColor, themeKey, userProfile?.theme] + ); + const normalizedDraftColor = normalizeNodedcAccentHex(draftColor); + const isDraftValid = !!normalizedDraftColor; + const isDirty = normalizedDraftColor !== savedColor; + + useEffect(() => { + setIsBrowser(true); + setTemplateColors(readTemplateColors()); + }, []); + + useEffect(() => { + setDraftColor(savedColor); + }, [savedColor]); + + const updatePalettePosition = useCallback(() => { + if (typeof window === "undefined" || !paletteButtonRef.current) return; + + const buttonRect = paletteButtonRef.current.getBoundingClientRect(); + const panelHeight = palettePanelRef.current?.offsetHeight || COLOR_PICKER_PANEL_ESTIMATED_HEIGHT; + const maxLeft = window.innerWidth - COLOR_PICKER_PANEL_WIDTH - COLOR_PICKER_VIEWPORT_MARGIN; + const left = clampNumber( + buttonRect.left - COLOR_PICKER_PANEL_GAP, + COLOR_PICKER_VIEWPORT_MARGIN, + Math.max(COLOR_PICKER_VIEWPORT_MARGIN, maxLeft) + ); + const topAbove = buttonRect.top - panelHeight - COLOR_PICKER_PANEL_GAP; + const topBelow = buttonRect.bottom + COLOR_PICKER_PANEL_GAP; + const maxTop = window.innerHeight - panelHeight - COLOR_PICKER_VIEWPORT_MARGIN; + const top = + topAbove >= COLOR_PICKER_VIEWPORT_MARGIN + ? topAbove + : clampNumber(topBelow, COLOR_PICKER_VIEWPORT_MARGIN, Math.max(COLOR_PICKER_VIEWPORT_MARGIN, maxTop)); + + setPanelPosition({ left, top }); + }, []); + + useEffect(() => { + if (!isPaletteOpen) return; + + updatePalettePosition(); + const animationFrame = window.requestAnimationFrame(updatePalettePosition); + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (paletteButtonRef.current?.contains(target) || palettePanelRef.current?.contains(target)) return; + setIsPaletteOpen(false); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsPaletteOpen(false); + }; + + window.addEventListener("resize", updatePalettePosition); + window.addEventListener("scroll", updatePalettePosition, true); + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + window.cancelAnimationFrame(animationFrame); + window.removeEventListener("resize", updatePalettePosition); + window.removeEventListener("scroll", updatePalettePosition, true); + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isPaletteOpen, updatePalettePosition]); + + const handleColorChange = (value: string) => { + const nextValue = value.startsWith("#") ? value : `#${value}`; + setDraftColor(nextValue); + + const normalizedValue = normalizeNodedcAccentHex(nextValue); + if (normalizedValue) applyColor(normalizedValue); + }; + + const handleColorPickerChange = (color: ColorResult) => { + handleColorChange(color.hex); + }; + + const handlePickColorFromScreen = async () => { + if (typeof window === "undefined" || !window.EyeDropper) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Недоступно", + message: "Пипетка не поддерживается этим браузером.", + }); + return; + } + + try { + const result = await new window.EyeDropper().open(); + handleColorChange(result.sRGBHex); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + setToast({ + type: TOAST_TYPE.ERROR, + title: "Ошибка", + message: "Не удалось взять цвет пипеткой.", + }); + } + }; + + const handleSaveTemplateColor = () => { + if (!normalizedDraftColor) return; + + const nextTemplateColors = normalizeTemplateColorList([ + normalizedDraftColor, + ...templateColors.filter((color) => color !== normalizedDraftColor), + ]); + setTemplateColors(nextTemplateColors); + writeTemplateColors(nextTemplateColors); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Сохранено", + message: "Цвет добавлен в шаблоны.", + }); + }; + + const handleRemoveTemplateColor = (color: string) => { + const nextTemplateColors = templateColors.filter((templateColor) => templateColor !== color); + setTemplateColors(nextTemplateColors); + writeTemplateColors(nextTemplateColors); + }; + + const handleSave = async () => { + if (!normalizedDraftColor) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Ошибка", + message: "Введите корректный HEX-цвет.", + }); + return; + } + + try { + setIsSaving(true); + applyColor(normalizedDraftColor); + await updateUserTheme({ [themeKey]: normalizedDraftColor }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Сохранено", + message: saveSuccessMessage, + }); + } catch (_error) { + applyColor(savedColor); + setDraftColor(savedColor); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Ошибка", + message: saveErrorMessage, + }); + } finally { + setIsSaving(false); + } + }; + + const handleReset = async () => { + try { + setIsSaving(true); + setDraftColor(defaultColor); + applyColor(defaultColor); + await updateUserTheme({ [themeKey]: undefined }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Сброшено", + message: resetSuccessMessage, + }); + } catch (_error) { + applyColor(savedColor); + setDraftColor(savedColor); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Ошибка", + message: resetErrorMessage, + }); + } finally { + setIsSaving(false); + } + }; + + const renderColorSwatch = (color: string, options?: { isTemplate?: boolean }) => { + const isSelected = color === normalizedDraftColor; + + return ( +
+ + {options?.isTemplate && ( + + )} +
+ ); + }; + + const colorPickerPanel = + isBrowser && isPaletteOpen + ? createPortal( +
+
+
+ + +
+
+ + {normalizedDraftColor || defaultColor} +
+
+ + + + {templateColors.length > 0 && ( +
+ {templateColors.map((color) => renderColorSwatch(color, { isTemplate: true }))} +
+ )} + +
0 ? "mt-3 border-t border-white/8 pt-3" : "mt-4" + )} + > + {presetColors.map((color) => renderColorSwatch(color))} +
+
, + document.body + ) + : null; + + return ( + +
+ + {colorPickerPanel} + handleColorChange(event.target.value)} + placeholder={defaultColor} + className="nodedc-settings-input h-11 min-h-11 w-full pr-4 pl-10 text-13 font-medium uppercase" + style={{ + color: normalizedDraftColor ? getReadableColor(normalizedDraftColor) : undefined, + }} + aria-invalid={!isDraftValid} + /> +
+
+ + +
+ + } + /> + ); +}); + +export const ProfileSettingsPassiveCardSurfaceColor = () => ( + +); + +export const ProfileSettingsPassiveCardColor = () => ( + +); diff --git a/plane-src/apps/web/core/helpers/nodedc-design.ts b/plane-src/apps/web/core/helpers/nodedc-design.ts index 0a5d89c..50d377a 100644 --- a/plane-src/apps/web/core/helpers/nodedc-design.ts +++ b/plane-src/apps/web/core/helpers/nodedc-design.ts @@ -8,5 +8,8 @@ import { rgbToNodedcAccentHex } from "@plane/utils"; import designConfig from "../../design.config.json"; const defaultAccentRgb = designConfig.nodedc.accent_rgb as [number, number, number]; +const defaultPassiveCardRgb = designConfig.nodedc.passive_card_rgb as [number, number, number]; export const NODEDC_DEFAULT_ACCENT_HEX = rgbToNodedcAccentHex(defaultAccentRgb); +export const NODEDC_DEFAULT_PASSIVE_CARD_HEX = rgbToNodedcAccentHex(defaultPassiveCardRgb); +export const NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX = rgbToNodedcAccentHex(defaultPassiveCardRgb); diff --git a/plane-src/apps/web/core/lib/wrappers/store-wrapper.tsx b/plane-src/apps/web/core/lib/wrappers/store-wrapper.tsx index 191de3b..1b848c4 100644 --- a/plane-src/apps/web/core/lib/wrappers/store-wrapper.tsx +++ b/plane-src/apps/web/core/lib/wrappers/store-wrapper.tsx @@ -12,8 +12,18 @@ import { useTheme } from "next-themes"; import type { TLanguage } from "@plane/i18n"; import { DEFAULT_LANGUAGE, useTranslation } from "@plane/i18n"; // helpers -import { applyCustomTheme, applyNodedcAccent, clearCustomTheme } from "@plane/utils"; -import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design"; +import { + applyCustomTheme, + applyNodedcAccent, + applyNodedcPassiveCardColor, + applyNodedcPassiveCardSurfaceColor, + clearCustomTheme, +} from "@plane/utils"; +import { + NODEDC_DEFAULT_ACCENT_HEX, + NODEDC_DEFAULT_PASSIVE_CARD_HEX, + NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX, +} from "../../helpers/nodedc-design"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useRouterParams } from "@/hooks/store/use-router-params"; @@ -113,6 +123,18 @@ function StoreWrapper(props: TStoreWrapper) { applyNodedcAccent(userProfile?.theme?.nodedcAccent || NODEDC_DEFAULT_ACCENT_HEX); }, [userProfile?.id, userProfile?.theme?.nodedcAccent]); + useEffect(() => { + if (!userProfile?.id) return; + applyNodedcPassiveCardColor(userProfile?.theme?.nodedcPassiveCard || NODEDC_DEFAULT_PASSIVE_CARD_HEX); + }, [userProfile?.id, userProfile?.theme?.nodedcPassiveCard]); + + useEffect(() => { + if (!userProfile?.id) return; + applyNodedcPassiveCardSurfaceColor( + userProfile?.theme?.nodedcPassiveCardSurface || NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX + ); + }, [userProfile?.id, userProfile?.theme?.nodedcPassiveCardSurface]); + useEffect(() => { if (!userProfile?.id) return; changeLanguage((userProfile?.language as TLanguage) || DEFAULT_LANGUAGE); diff --git a/plane-src/apps/web/core/store/user/profile.store.ts b/plane-src/apps/web/core/store/user/profile.store.ts index 96b41f4..3107e2e 100644 --- a/plane-src/apps/web/core/store/user/profile.store.ts +++ b/plane-src/apps/web/core/store/user/profile.store.ts @@ -46,6 +46,8 @@ export class ProfileStore implements IUserProfileStore { background: undefined, darkPalette: false, nodedcAccent: undefined, + nodedcPassiveCard: undefined, + nodedcPassiveCardSurface: undefined, nodedcCompactToolbar: undefined, }, onboarding_step: { diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 14aa2a8..5c1b4a3 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -30,7 +30,10 @@ --nodedc-accent-rgb: 51 163 255; --nodedc-on-accent-rgb: 245 247 251; --nodedc-card-passive-rgb: 42 43 46; + --nodedc-card-passive-surface-rgb: 42 43 46; + --nodedc-presence-dot-border-rgb: 42 43 46; --nodedc-on-card-passive-rgb: 245 247 251; + --nodedc-on-card-passive-surface-rgb: 245 247 251; --nodedc-card-active-rgb: 195 255 102; --nodedc-on-card-active-rgb: 11 17 23; --nodedc-priority-none-rgb: 124 128 138; @@ -207,8 +210,8 @@ color: rgb(var(--nodedc-on-card-active-rgb)); } 100% { - background-color: rgb(var(--nodedc-card-passive-rgb)); - color: rgb(var(--nodedc-on-card-passive-rgb)); + background-color: rgb(var(--nodedc-card-passive-surface-rgb)); + color: rgb(var(--nodedc-on-card-passive-surface-rgb)); } } @@ -234,7 +237,7 @@ outline: none !important; background: linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.006) 100%), - rgb(var(--nodedc-card-passive-rgb)) !important; + rgb(var(--nodedc-card-passive-surface-rgb)) !important; box-shadow: 0 14px 34px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.035) !important; @@ -2243,7 +2246,7 @@ border-radius: 2rem !important; background: linear-gradient(180deg, rgba(255, 255, 255, 0.018) 0%, rgba(255, 255, 255, 0.006) 100%), - rgb(var(--nodedc-card-passive-rgb)) !important; + rgb(var(--nodedc-card-passive-surface-rgb)) !important; color: var(--text-color-primary) !important; } @@ -2291,6 +2294,46 @@ color: rgb(var(--nodedc-on-card-active-rgb)) !important; } + .nodedc-work-item-card:not([data-active="true"]), + .nodedc-external-card:not([data-active="true"]) { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.022) 0%, rgba(255, 255, 255, 0.006) 100%), + rgb(var(--nodedc-card-passive-surface-rgb)) !important; + -webkit-backdrop-filter: blur(28px); + backdrop-filter: blur(28px); + color: rgb(var(--nodedc-on-card-passive-surface-rgb)) !important; + box-shadow: + 0 12px 28px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.02) !important; + } + + .nodedc-work-item-card:not([data-active="true"])::before, + .nodedc-external-card:not([data-active="true"])::before { + content: "" !important; + display: block !important; + position: absolute; + inset: 0; + z-index: 0; + background: + radial-gradient(circle at top right, rgba(var(--nodedc-accent-rgb), 0.08), transparent 34%), + linear-gradient(180deg, rgba(255, 255, 255, 0.01) 0%, transparent 100%); + pointer-events: none; + } + + .nodedc-work-item-card:not([data-active="true"]) .text-primary, + .nodedc-external-card:not([data-active="true"]) .text-primary { + color: rgb(var(--nodedc-on-card-passive-surface-rgb)) !important; + } + + .nodedc-work-item-card:not([data-active="true"]) .text-secondary, + .nodedc-work-item-card:not([data-active="true"]) .text-tertiary, + .nodedc-work-item-card:not([data-active="true"]) .text-placeholder, + .nodedc-external-card:not([data-active="true"]) .text-secondary, + .nodedc-external-card:not([data-active="true"]) .text-tertiary, + .nodedc-external-card:not([data-active="true"]) .text-placeholder { + color: rgb(var(--nodedc-on-card-passive-surface-rgb) / 0.7) !important; + } + .nodedc-external-content-shell { border: 0 !important; outline: none !important; @@ -4294,8 +4337,9 @@ .nodedc-home-task-card-surface-passive { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(46, 46, 50, 0.9) !important; - color: rgba(245, 245, 247, 0.58) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.008) 100%), + rgb(var(--nodedc-card-passive-surface-rgb)) !important; + color: rgb(var(--nodedc-on-card-passive-surface-rgb)) !important; } .nodedc-home-task-card-surface-active { diff --git a/plane-src/packages/types/src/current-user/profile.ts b/plane-src/packages/types/src/current-user/profile.ts index e650131..fb1a6e2 100644 --- a/plane-src/packages/types/src/current-user/profile.ts +++ b/plane-src/packages/types/src/current-user/profile.ts @@ -14,6 +14,8 @@ export type TUserProfile = { theme: { theme: string | undefined; nodedcAccent?: string | undefined; + nodedcPassiveCard?: string | undefined; + nodedcPassiveCardSurface?: string | undefined; nodedcCompactToolbar?: boolean | undefined; }; diff --git a/plane-src/packages/types/src/users.ts b/plane-src/packages/types/src/users.ts index b5703d9..5b85b2c 100644 --- a/plane-src/packages/types/src/users.ts +++ b/plane-src/packages/types/src/users.ts @@ -70,6 +70,8 @@ export type TUserProfile = { background: string | undefined; darkPalette: boolean | undefined; nodedcAccent?: string | undefined; + nodedcPassiveCard?: string | undefined; + nodedcPassiveCardSurface?: string | undefined; nodedcCompactToolbar?: boolean | undefined; }; onboarding_step: TOnboardingSteps; @@ -110,6 +112,8 @@ export interface IUserTheme { background?: string | undefined; darkPalette?: boolean | undefined; nodedcAccent?: string | undefined; + nodedcPassiveCard?: string | undefined; + nodedcPassiveCardSurface?: string | undefined; nodedcCompactToolbar?: boolean | undefined; } diff --git a/plane-src/packages/utils/src/theme/index.ts b/plane-src/packages/utils/src/theme/index.ts index 162d09c..2fe328f 100644 --- a/plane-src/packages/utils/src/theme/index.ts +++ b/plane-src/packages/utils/src/theme/index.ts @@ -30,6 +30,8 @@ export { // NODE.DC runtime accent export { applyNodedcAccent, + applyNodedcPassiveCardColor, + applyNodedcPassiveCardSurfaceColor, getReadableNodedcTextRgb, nodedcAccentHexToRgb, normalizeNodedcAccentHex, diff --git a/plane-src/packages/utils/src/theme/nodedc-accent.ts b/plane-src/packages/utils/src/theme/nodedc-accent.ts index 8271e40..5200fc8 100644 --- a/plane-src/packages/utils/src/theme/nodedc-accent.ts +++ b/plane-src/packages/utils/src/theme/nodedc-accent.ts @@ -100,3 +100,33 @@ export const applyNodedcAccent = (hex: string | null | undefined): boolean => { return true; }; + +export const applyNodedcPassiveCardColor = (hex: string | null | undefined): boolean => { + if (typeof document === "undefined") return false; + + const passiveCardRgb = nodedcAccentHexToRgb(hex); + if (!passiveCardRgb) return false; + + const root = document.documentElement; + const onPassiveCardRgb = getReadableNodedcTextRgb(passiveCardRgb); + + root.style.setProperty("--nodedc-card-passive-rgb", formatRgbTuple(passiveCardRgb)); + root.style.setProperty("--nodedc-on-card-passive-rgb", formatRgbTuple(onPassiveCardRgb)); + + return true; +}; + +export const applyNodedcPassiveCardSurfaceColor = (hex: string | null | undefined): boolean => { + if (typeof document === "undefined") return false; + + const passiveCardSurfaceRgb = nodedcAccentHexToRgb(hex); + if (!passiveCardSurfaceRgb) return false; + + const root = document.documentElement; + const onPassiveCardSurfaceRgb = getReadableNodedcTextRgb(passiveCardSurfaceRgb); + + root.style.setProperty("--nodedc-card-passive-surface-rgb", formatRgbTuple(passiveCardSurfaceRgb)); + root.style.setProperty("--nodedc-on-card-passive-surface-rgb", formatRgbTuple(onPassiveCardSurfaceRgb)); + + return true; +};