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;
|
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 accentRgb = designConfig.nodedc.accent_rgb as [number, number, number];
|
||||||
const activeCardRgb = designConfig.nodedc.active_card_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 = {
|
const designConfigStyle = {
|
||||||
"--nodedc-accent-rgb": formatRgbTuple(accentRgb),
|
"--nodedc-accent-rgb": formatRgbTuple(accentRgb),
|
||||||
"--nodedc-card-passive-rgb": formatRgbTuple(passiveCardRgb),
|
"--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-card-active-rgb": formatRgbTuple(activeCardRgb),
|
||||||
"--nodedc-on-accent-rgb": formatRgbTuple(onAccentRgb),
|
"--nodedc-on-accent-rgb": formatRgbTuple(onAccentRgb),
|
||||||
"--nodedc-on-card-active-rgb": formatRgbTuple(onActiveCardRgb),
|
"--nodedc-on-card-active-rgb": formatRgbTuple(onActiveCardRgb),
|
||||||
"--nodedc-on-card-passive-rgb": formatRgbTuple(onPassiveCardRgb),
|
"--nodedc-on-card-passive-rgb": formatRgbTuple(onPassiveCardRgb),
|
||||||
|
"--nodedc-on-card-passive-surface-rgb": formatRgbTuple(onPassiveCardRgb),
|
||||||
"--brand-default": formatCssRgb(accentRgb),
|
"--brand-default": formatCssRgb(accentRgb),
|
||||||
"--brand-300": formatCssRgb(blendRgb(accentRgb, 255, 0.35)),
|
"--brand-300": formatCssRgb(blendRgb(accentRgb, 255, 0.35)),
|
||||||
"--brand-700": formatCssRgb(blendRgb(accentRgb, 0, 0.25)),
|
"--brand-700": formatCssRgb(blendRgb(accentRgb, 0, 0.25)),
|
||||||
|
|
|
||||||
|
|
@ -128,11 +128,13 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||||
const stateOptions = canEditTargetIssue
|
const stateOptions = canEditTargetIssue
|
||||||
? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state)
|
? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state)
|
||||||
: sourceStateIds.map((stateId) => sourceStateMap[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 subtleTextClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]";
|
||||||
const pillBackgroundClasses = isActive
|
const pillBackgroundClasses = isActive
|
||||||
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
? "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(
|
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",
|
"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
|
isActive
|
||||||
|
|
@ -141,7 +143,9 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||||
);
|
);
|
||||||
const assigneeButtonClasses = cn(
|
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",
|
"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 dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
|
||||||
const checkerBlocksTotal = issue.checker_blocks_count ?? 0;
|
const checkerBlocksTotal = issue.checker_blocks_count ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,10 @@ type TNodedcWorkItemCardProps = {
|
||||||
export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
|
export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
|
||||||
surfaceClassName: isActive
|
surfaceClassName: isActive
|
||||||
? "bg-[rgb(var(--nodedc-card-active-rgb))] text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
? "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
|
foregroundClasses: isActive
|
||||||
? "text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
? "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]",
|
subtleTextClasses: isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]",
|
||||||
pillBackgroundClasses: isActive
|
pillBackgroundClasses: isActive
|
||||||
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export const PresenceDot = (props: Props) => {
|
||||||
<span
|
<span
|
||||||
aria-label="Пользователь онлайн"
|
aria-label="Пользователь онлайн"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,12 @@
|
||||||
* See the LICENSE file for details.
|
* 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
|
// plane imports
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
import { applyNodedcAccent } from "@plane/utils";
|
||||||
import {
|
|
||||||
applyNodedcAccent,
|
|
||||||
getReadableNodedcTextRgb,
|
|
||||||
nodedcAccentHexToRgb,
|
|
||||||
normalizeNodedcAccentHex,
|
|
||||||
} from "@plane/utils";
|
|
||||||
// helpers
|
// helpers
|
||||||
import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design";
|
import { NODEDC_DEFAULT_ACCENT_HEX } from "../../../../../../helpers/nodedc-design";
|
||||||
// components
|
// local imports
|
||||||
import { SettingsControlItem } from "@/components/settings/control-item";
|
import { ProfileSettingsNodedcColorControl } from "./passive-card-color";
|
||||||
// hooks
|
|
||||||
import { useUserProfile } from "@/hooks/store/user";
|
|
||||||
|
|
||||||
const ACCENT_PRESET_COLORS = [
|
const ACCENT_PRESET_COLORS = [
|
||||||
"#EF4444",
|
"#EF4444",
|
||||||
|
|
@ -42,242 +29,19 @@ const ACCENT_PRESET_COLORS = [
|
||||||
"#F5F7FB",
|
"#F5F7FB",
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHROME_PICKER_STYLES = {
|
export const ProfileSettingsAccentColor = () => (
|
||||||
default: {
|
<ProfileSettingsNodedcColorControl
|
||||||
picker: {
|
themeKey="nodedcAccent"
|
||||||
width: "100%",
|
defaultColor={NODEDC_DEFAULT_ACCENT_HEX}
|
||||||
background: "transparent",
|
title="Акцентный цвет"
|
||||||
borderRadius: 0,
|
description="Локальная настройка пользователя. Меняет цвет кнопок, активных элементов, шкал и выделений без перезапуска системы."
|
||||||
boxShadow: "none",
|
inputName="nodedcAccent"
|
||||||
fontFamily: "inherit",
|
paletteLabel="Открыть палитру акцентного цвета"
|
||||||
},
|
saveSuccessMessage="Акцентный цвет обновлен."
|
||||||
saturation: {
|
saveErrorMessage="Не удалось сохранить акцентный цвет."
|
||||||
borderRadius: "1.35rem",
|
resetSuccessMessage="Акцентный цвет возвращен к дизайн-конфигу."
|
||||||
overflow: "hidden",
|
resetErrorMessage="Не удалось сбросить акцентный цвет."
|
||||||
},
|
presetColors={ACCENT_PRESET_COLORS}
|
||||||
Saturation: {
|
applyColor={applyNodedcAccent}
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { observer } from "mobx-react";
|
||||||
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
|
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
|
||||||
// local imports
|
// local imports
|
||||||
import { ProfileSettingsAccentColor } from "./accent-color";
|
import { ProfileSettingsAccentColor } from "./accent-color";
|
||||||
|
import { ProfileSettingsPassiveCardColor, ProfileSettingsPassiveCardSurfaceColor } from "./passive-card-color";
|
||||||
import { ProfileSettingsToolbarLayout } from "./toolbar-layout";
|
import { ProfileSettingsToolbarLayout } from "./toolbar-layout";
|
||||||
|
|
||||||
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
|
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
|
||||||
|
|
@ -22,6 +23,8 @@ export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSe
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ProfileSettingsAccentColor />
|
<ProfileSettingsAccentColor />
|
||||||
|
<ProfileSettingsPassiveCardSurfaceColor />
|
||||||
|
<ProfileSettingsPassiveCardColor />
|
||||||
<ProfileSettingsToolbarLayout />
|
<ProfileSettingsToolbarLayout />
|
||||||
</div>
|
</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";
|
import designConfig from "../../design.config.json";
|
||||||
|
|
||||||
const defaultAccentRgb = designConfig.nodedc.accent_rgb as [number, number, number];
|
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_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 type { TLanguage } from "@plane/i18n";
|
||||||
import { DEFAULT_LANGUAGE, useTranslation } from "@plane/i18n";
|
import { DEFAULT_LANGUAGE, useTranslation } from "@plane/i18n";
|
||||||
// helpers
|
// helpers
|
||||||
import { applyCustomTheme, applyNodedcAccent, clearCustomTheme } from "@plane/utils";
|
import {
|
||||||
import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design";
|
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
|
// hooks
|
||||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||||
import { useRouterParams } from "@/hooks/store/use-router-params";
|
import { useRouterParams } from "@/hooks/store/use-router-params";
|
||||||
|
|
@ -113,6 +123,18 @@ function StoreWrapper(props: TStoreWrapper) {
|
||||||
applyNodedcAccent(userProfile?.theme?.nodedcAccent || NODEDC_DEFAULT_ACCENT_HEX);
|
applyNodedcAccent(userProfile?.theme?.nodedcAccent || NODEDC_DEFAULT_ACCENT_HEX);
|
||||||
}, [userProfile?.id, userProfile?.theme?.nodedcAccent]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!userProfile?.id) return;
|
if (!userProfile?.id) return;
|
||||||
changeLanguage((userProfile?.language as TLanguage) || DEFAULT_LANGUAGE);
|
changeLanguage((userProfile?.language as TLanguage) || DEFAULT_LANGUAGE);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ export class ProfileStore implements IUserProfileStore {
|
||||||
background: undefined,
|
background: undefined,
|
||||||
darkPalette: false,
|
darkPalette: false,
|
||||||
nodedcAccent: undefined,
|
nodedcAccent: undefined,
|
||||||
|
nodedcPassiveCard: undefined,
|
||||||
|
nodedcPassiveCardSurface: undefined,
|
||||||
nodedcCompactToolbar: undefined,
|
nodedcCompactToolbar: undefined,
|
||||||
},
|
},
|
||||||
onboarding_step: {
|
onboarding_step: {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@
|
||||||
--nodedc-accent-rgb: 51 163 255;
|
--nodedc-accent-rgb: 51 163 255;
|
||||||
--nodedc-on-accent-rgb: 245 247 251;
|
--nodedc-on-accent-rgb: 245 247 251;
|
||||||
--nodedc-card-passive-rgb: 42 43 46;
|
--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-rgb: 245 247 251;
|
||||||
|
--nodedc-on-card-passive-surface-rgb: 245 247 251;
|
||||||
--nodedc-card-active-rgb: 195 255 102;
|
--nodedc-card-active-rgb: 195 255 102;
|
||||||
--nodedc-on-card-active-rgb: 11 17 23;
|
--nodedc-on-card-active-rgb: 11 17 23;
|
||||||
--nodedc-priority-none-rgb: 124 128 138;
|
--nodedc-priority-none-rgb: 124 128 138;
|
||||||
|
|
@ -207,8 +210,8 @@
|
||||||
color: rgb(var(--nodedc-on-card-active-rgb));
|
color: rgb(var(--nodedc-on-card-active-rgb));
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background-color: rgb(var(--nodedc-card-passive-rgb));
|
background-color: rgb(var(--nodedc-card-passive-surface-rgb));
|
||||||
color: rgb(var(--nodedc-on-card-passive-rgb));
|
color: rgb(var(--nodedc-on-card-passive-surface-rgb));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,7 +237,7 @@
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.006) 100%),
|
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:
|
box-shadow:
|
||||||
0 14px 34px rgba(0, 0, 0, 0.24),
|
0 14px 34px rgba(0, 0, 0, 0.24),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
|
inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
|
||||||
|
|
@ -2243,7 +2246,7 @@
|
||||||
border-radius: 2rem !important;
|
border-radius: 2rem !important;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.018) 0%, rgba(255, 255, 255, 0.006) 100%),
|
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;
|
color: var(--text-color-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2291,6 +2294,46 @@
|
||||||
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
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 {
|
.nodedc-external-content-shell {
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
|
|
@ -4294,8 +4337,9 @@
|
||||||
|
|
||||||
.nodedc-home-task-card-surface-passive {
|
.nodedc-home-task-card-surface-passive {
|
||||||
background:
|
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;
|
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.008) 100%),
|
||||||
color: rgba(245, 245, 247, 0.58) !important;
|
rgb(var(--nodedc-card-passive-surface-rgb)) !important;
|
||||||
|
color: rgb(var(--nodedc-on-card-passive-surface-rgb)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodedc-home-task-card-surface-active {
|
.nodedc-home-task-card-surface-active {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export type TUserProfile = {
|
||||||
theme: {
|
theme: {
|
||||||
theme: string | undefined;
|
theme: string | undefined;
|
||||||
nodedcAccent?: string | undefined;
|
nodedcAccent?: string | undefined;
|
||||||
|
nodedcPassiveCard?: string | undefined;
|
||||||
|
nodedcPassiveCardSurface?: string | undefined;
|
||||||
nodedcCompactToolbar?: boolean | undefined;
|
nodedcCompactToolbar?: boolean | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ export type TUserProfile = {
|
||||||
background: string | undefined;
|
background: string | undefined;
|
||||||
darkPalette: boolean | undefined;
|
darkPalette: boolean | undefined;
|
||||||
nodedcAccent?: string | undefined;
|
nodedcAccent?: string | undefined;
|
||||||
|
nodedcPassiveCard?: string | undefined;
|
||||||
|
nodedcPassiveCardSurface?: string | undefined;
|
||||||
nodedcCompactToolbar?: boolean | undefined;
|
nodedcCompactToolbar?: boolean | undefined;
|
||||||
};
|
};
|
||||||
onboarding_step: TOnboardingSteps;
|
onboarding_step: TOnboardingSteps;
|
||||||
|
|
@ -110,6 +112,8 @@ export interface IUserTheme {
|
||||||
background?: string | undefined;
|
background?: string | undefined;
|
||||||
darkPalette?: boolean | undefined;
|
darkPalette?: boolean | undefined;
|
||||||
nodedcAccent?: string | undefined;
|
nodedcAccent?: string | undefined;
|
||||||
|
nodedcPassiveCard?: string | undefined;
|
||||||
|
nodedcPassiveCardSurface?: string | undefined;
|
||||||
nodedcCompactToolbar?: boolean | undefined;
|
nodedcCompactToolbar?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export {
|
||||||
// NODE.DC runtime accent
|
// NODE.DC runtime accent
|
||||||
export {
|
export {
|
||||||
applyNodedcAccent,
|
applyNodedcAccent,
|
||||||
|
applyNodedcPassiveCardColor,
|
||||||
|
applyNodedcPassiveCardSurfaceColor,
|
||||||
getReadableNodedcTextRgb,
|
getReadableNodedcTextRgb,
|
||||||
nodedcAccentHexToRgb,
|
nodedcAccentHexToRgb,
|
||||||
normalizeNodedcAccentHex,
|
normalizeNodedcAccentHex,
|
||||||
|
|
|
||||||
|
|
@ -100,3 +100,33 @@ export const applyNodedcAccent = (hex: string | null | undefined): boolean => {
|
||||||
|
|
||||||
return true;
|
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