UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: контроль цветов элементов, допил палитры и реворк основного UI
This commit is contained in:
parent
7ff7d83b07
commit
a7ab8ee123
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))]"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const PresenceDot = (props: Props) => {
|
|||
<span
|
||||
aria-label="Пользователь онлайн"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 border-[rgb(var(--nodedc-card-passive-rgb))] bg-[#B8FF4D] shadow-[0_0_0_1px_rgba(0,0,0,0.22)]",
|
||||
"pointer-events-none absolute right-0 bottom-0 h-3 w-3 rounded-full border-2 border-[rgb(var(--nodedc-presence-dot-border-rgb))] bg-[#B8FF4D] shadow-[0_0_0_1px_rgba(0,0,0,0.22)]",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<SettingsControlItem
|
||||
title="Акцентный цвет"
|
||||
description="Локальная настройка пользователя. Меняет цвет кнопок, активных элементов, шкал и выделений без перезапуска системы."
|
||||
control={
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-40">
|
||||
<Popover as="div" className="absolute top-1/2 left-3 z-20 -translate-y-1/2">
|
||||
{() => (
|
||||
<>
|
||||
<Popover.Button
|
||||
type="button"
|
||||
className="grid size-5 place-items-center rounded-full outline-none transition-transform hover:scale-105 focus-visible:scale-105"
|
||||
aria-label="Открыть палитру акцентного цвета"
|
||||
>
|
||||
<span
|
||||
className="size-4 rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.24)]"
|
||||
style={{ backgroundColor: normalizedDraftAccent || NODEDC_DEFAULT_ACCENT_HEX }}
|
||||
/>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1 scale-95"
|
||||
enterTo="opacity-100 translate-y-0 scale-100"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100 translate-y-0 scale-100"
|
||||
leaveTo="opacity-0 translate-y-1 scale-95"
|
||||
>
|
||||
<Popover.Panel className="nodedc-accent-picker-panel absolute top-full left-0 z-[90] mt-4 w-[21rem] origin-top-left rounded-[1.75rem] p-4 shadow-[0_28px_80px_rgba(0,0,0,0.46)]">
|
||||
<ColorPicker.ChromePicker
|
||||
className="nodedc-accent-chrome-picker"
|
||||
color={normalizedDraftAccent || NODEDC_DEFAULT_ACCENT_HEX}
|
||||
disableAlpha
|
||||
onChange={handleColorPickerChange}
|
||||
styles={CHROME_PICKER_STYLES}
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-8 gap-2">
|
||||
{ACCENT_PRESET_COLORS.map((color) => {
|
||||
const isSelected = color === normalizedDraftAccent;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className="grid size-7 place-items-center rounded-full transition-transform hover:scale-105 focus-visible:scale-105"
|
||||
onClick={() => handleAccentChange(color)}
|
||||
aria-label={`Выбрать цвет ${color}`}
|
||||
>
|
||||
<span
|
||||
className="size-5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 2px rgba(245,247,251,0.92), 0 0 0 5px ${color}`
|
||||
: "0 0 0 1px rgba(255,255,255,0.16)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<input
|
||||
name="nodedcAccent"
|
||||
value={draftAccent}
|
||||
onChange={(event) => 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-settings-primary-button min-w-[7rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleSave}
|
||||
disabled={!isDraftValid || !isDirty || isSaving}
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-settings-secondary-button min-w-[6rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleReset}
|
||||
disabled={savedAccent === NODEDC_DEFAULT_ACCENT_HEX || isSaving}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
export const ProfileSettingsAccentColor = () => (
|
||||
<ProfileSettingsNodedcColorControl
|
||||
themeKey="nodedcAccent"
|
||||
defaultColor={NODEDC_DEFAULT_ACCENT_HEX}
|
||||
title="Акцентный цвет"
|
||||
description="Локальная настройка пользователя. Меняет цвет кнопок, активных элементов, шкал и выделений без перезапуска системы."
|
||||
inputName="nodedcAccent"
|
||||
paletteLabel="Открыть палитру акцентного цвета"
|
||||
saveSuccessMessage="Акцентный цвет обновлен."
|
||||
saveErrorMessage="Не удалось сохранить акцентный цвет."
|
||||
resetSuccessMessage="Акцентный цвет возвращен к дизайн-конфигу."
|
||||
resetErrorMessage="Не удалось сбросить акцентный цвет."
|
||||
presetColors={ACCENT_PRESET_COLORS}
|
||||
applyColor={applyNodedcAccent}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}}
|
||||
/>
|
||||
<ProfileSettingsAccentColor />
|
||||
<ProfileSettingsPassiveCardSurfaceColor />
|
||||
<ProfileSettingsPassiveCardColor />
|
||||
<ProfileSettingsToolbarLayout />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<TEyeDropperResult>;
|
||||
};
|
||||
|
||||
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<string[]>([]);
|
||||
const paletteButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const palettePanelRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div key={color} className={cn("relative grid size-7 place-items-center", options?.isTemplate && "group")}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-7 place-items-center rounded-full transition-transform hover:scale-105 focus-visible:scale-105"
|
||||
onClick={() => handleColorChange(color)}
|
||||
aria-label={`Выбрать цвет ${color}`}
|
||||
>
|
||||
<span
|
||||
className="size-5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 2px rgba(245,247,251,0.92), 0 0 0 5px ${color}`
|
||||
: "0 0 0 1px rgba(255,255,255,0.16)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{options?.isTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-1 -right-1 grid size-4 place-items-center rounded-full bg-black/70 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-black focus-visible:opacity-100"
|
||||
onClick={() => handleRemoveTemplateColor(color)}
|
||||
aria-label={`Удалить шаблон ${color}`}
|
||||
title="Удалить шаблон"
|
||||
>
|
||||
<Trash2 className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const colorPickerPanel =
|
||||
isBrowser && isPaletteOpen
|
||||
? createPortal(
|
||||
<div
|
||||
ref={palettePanelRef}
|
||||
className="nodedc-accent-picker-panel fixed z-[9999] max-h-[calc(100vh-2rem)] overflow-y-auto rounded-[1.75rem] p-4 shadow-[0_28px_80px_rgba(0,0,0,0.46)]"
|
||||
style={{
|
||||
left: panelPosition.left,
|
||||
top: panelPosition.top,
|
||||
width: COLOR_PICKER_PANEL_WIDTH,
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-9 place-items-center rounded-full bg-white/7 text-white transition-colors hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={handlePickColorFromScreen}
|
||||
aria-label="Взять цвет пипеткой"
|
||||
title="Пипетка"
|
||||
>
|
||||
<Pipette className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-9 place-items-center rounded-full bg-white/7 text-white transition-colors hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={handleSaveTemplateColor}
|
||||
disabled={!isDraftValid}
|
||||
aria-label="Сохранить цвет как шаблон"
|
||||
title="Сохранить как шаблон"
|
||||
>
|
||||
<BookmarkPlus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-full bg-white/7 py-1 pr-3 pl-1 text-11 font-medium text-primary">
|
||||
<span
|
||||
className="size-5 shrink-0 rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.2)]"
|
||||
style={{ backgroundColor: normalizedDraftColor || defaultColor }}
|
||||
/>
|
||||
<span className="truncate uppercase">{normalizedDraftColor || defaultColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ColorPicker.ChromePicker
|
||||
className="nodedc-accent-chrome-picker"
|
||||
color={normalizedDraftColor || defaultColor}
|
||||
disableAlpha
|
||||
onChange={handleColorPickerChange}
|
||||
styles={CHROME_PICKER_STYLES}
|
||||
/>
|
||||
|
||||
{templateColors.length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-8 gap-2">
|
||||
{templateColors.map((color) => renderColorSwatch(color, { isTemplate: true }))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-8 gap-2",
|
||||
templateColors.length > 0 ? "mt-3 border-t border-white/8 pt-3" : "mt-4"
|
||||
)}
|
||||
>
|
||||
{presetColors.map((color) => renderColorSwatch(color))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SettingsControlItem
|
||||
title={title}
|
||||
description={description}
|
||||
control={
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-40">
|
||||
<button
|
||||
ref={paletteButtonRef}
|
||||
type="button"
|
||||
className="absolute top-1/2 left-3 z-20 grid size-5 -translate-y-1/2 place-items-center rounded-full transition-transform outline-none hover:scale-105 focus-visible:scale-105"
|
||||
onClick={() => setIsPaletteOpen((isOpen) => !isOpen)}
|
||||
aria-label={paletteLabel}
|
||||
aria-expanded={isPaletteOpen}
|
||||
>
|
||||
<span
|
||||
className="size-4 rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.24)]"
|
||||
style={{ backgroundColor: normalizedDraftColor || defaultColor }}
|
||||
/>
|
||||
</button>
|
||||
{colorPickerPanel}
|
||||
<input
|
||||
name={inputName}
|
||||
value={draftColor}
|
||||
onChange={(event) => 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}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-settings-primary-button min-w-[7rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleSave}
|
||||
disabled={!isDraftValid || !isDirty || isSaving}
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-settings-secondary-button min-w-[6rem] text-13 font-medium disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleReset}
|
||||
disabled={savedColor === defaultColor || isSaving}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ProfileSettingsPassiveCardSurfaceColor = () => (
|
||||
<ProfileSettingsNodedcColorControl
|
||||
themeKey="nodedcPassiveCardSurface"
|
||||
defaultColor={NODEDC_DEFAULT_PASSIVE_CARD_SURFACE_HEX}
|
||||
title="Фон пассивных карточек"
|
||||
description="Локальная настройка пользователя. Меняет фон невыделенных карточек во внутреннем и внешнем контурах без перезапуска системы."
|
||||
inputName="nodedcPassiveCardSurface"
|
||||
paletteLabel="Открыть палитру фона пассивных карточек"
|
||||
saveSuccessMessage="Фон пассивных карточек обновлен."
|
||||
saveErrorMessage="Не удалось сохранить фон пассивных карточек."
|
||||
resetSuccessMessage="Фон пассивных карточек возвращен к дизайн-конфигу."
|
||||
resetErrorMessage="Не удалось сбросить фон пассивных карточек."
|
||||
presetColors={PASSIVE_CARD_PRESET_COLORS}
|
||||
applyColor={applyNodedcPassiveCardSurfaceColor}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ProfileSettingsPassiveCardColor = () => (
|
||||
<ProfileSettingsNodedcColorControl
|
||||
themeKey="nodedcPassiveCard"
|
||||
defaultColor={NODEDC_DEFAULT_PASSIVE_CARD_HEX}
|
||||
title="Цвет дат на карточках"
|
||||
description="Локальная настройка пользователя. Меняет цвет плашек дат и служебных бейджей на карточках."
|
||||
inputName="nodedcPassiveCard"
|
||||
paletteLabel="Открыть палитру цвета дат на карточках"
|
||||
saveSuccessMessage="Цвет дат на карточках обновлен."
|
||||
saveErrorMessage="Не удалось сохранить цвет дат на карточках."
|
||||
resetSuccessMessage="Цвет дат на карточках возвращен к дизайн-конфигу."
|
||||
resetErrorMessage="Не удалось сбросить цвет дат на карточках."
|
||||
presetColors={PASSIVE_CARD_PRESET_COLORS}
|
||||
applyColor={applyNodedcPassiveCardColor}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export class ProfileStore implements IUserProfileStore {
|
|||
background: undefined,
|
||||
darkPalette: false,
|
||||
nodedcAccent: undefined,
|
||||
nodedcPassiveCard: undefined,
|
||||
nodedcPassiveCardSurface: undefined,
|
||||
nodedcCompactToolbar: undefined,
|
||||
},
|
||||
onboarding_step: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export type TUserProfile = {
|
|||
theme: {
|
||||
theme: string | undefined;
|
||||
nodedcAccent?: string | undefined;
|
||||
nodedcPassiveCard?: string | undefined;
|
||||
nodedcPassiveCardSurface?: string | undefined;
|
||||
nodedcCompactToolbar?: boolean | undefined;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export {
|
|||
// NODE.DC runtime accent
|
||||
export {
|
||||
applyNodedcAccent,
|
||||
applyNodedcPassiveCardColor,
|
||||
applyNodedcPassiveCardSurfaceColor,
|
||||
getReadableNodedcTextRgb,
|
||||
nodedcAccentHexToRgb,
|
||||
normalizeNodedcAccentHex,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue