UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: локальный акцент и аналитика
This commit is contained in:
@@ -23,6 +23,8 @@ import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import { ChartLoader } from "../loaders";
|
||||
|
||||
const analyticsService = new AnalyticsService();
|
||||
const NODEDC_ANALYTICS_ACCENT = "rgb(var(--nodedc-accent-rgb))";
|
||||
const NODEDC_ANALYTICS_ACCENT_SOFT = "rgb(var(--nodedc-accent-rgb) / 0.18)";
|
||||
const CreatedVsResolved = observer(function CreatedVsResolved() {
|
||||
const {
|
||||
selectedDuration,
|
||||
@@ -66,12 +68,12 @@ const CreatedVsResolved = observer(function CreatedVsResolved() {
|
||||
{
|
||||
key: "completed_issues",
|
||||
label: "Решено",
|
||||
fill: "rgba(195, 255, 102, 0.18)",
|
||||
fill: NODEDC_ANALYTICS_ACCENT_SOFT,
|
||||
fillOpacity: 1,
|
||||
stackId: "bar-one",
|
||||
showDot: false,
|
||||
smoothCurves: true,
|
||||
strokeColor: "#C3FF66",
|
||||
strokeColor: NODEDC_ANALYTICS_ACCENT,
|
||||
strokeOpacity: 1,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ParamsProps {
|
||||
}
|
||||
|
||||
export const NODEDC_ANALYTICS_COLORS = [
|
||||
"#C3FF66",
|
||||
"rgb(var(--nodedc-accent-rgb))",
|
||||
"#F5F7FB",
|
||||
"#7C7F85",
|
||||
"#050505",
|
||||
@@ -29,12 +29,12 @@ const STATE_GROUP_COLORS: Record<TStateGroups, string> = {
|
||||
backlog: "#050505",
|
||||
unstarted: "#7C7F85",
|
||||
started: "#FFFFFF",
|
||||
completed: "#C3FF66",
|
||||
completed: "rgb(var(--nodedc-accent-rgb))",
|
||||
cancelled: "#050505",
|
||||
};
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: "#C3FF66",
|
||||
urgent: "rgb(var(--nodedc-accent-rgb))",
|
||||
high: "#F5F7FB",
|
||||
medium: "#7C7F85",
|
||||
low: "#2A2B2E",
|
||||
|
||||
+6
-1
@@ -206,7 +206,12 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
|
||||
|
||||
<div className="min-w-0 pr-[162px] pl-[58px] pt-1">
|
||||
<div className="truncate text-body-sm-medium leading-5">{creatorName}</div>
|
||||
<div className={cn("truncate text-[10px] leading-3.5 font-medium", isActive ? "text-[#2F4721]" : "text-[#B3B3B8]")}>
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-[10px] leading-3.5 font-medium",
|
||||
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]"
|
||||
)}
|
||||
>
|
||||
{sourceContourName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-4
@@ -30,7 +30,7 @@ export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({
|
||||
foregroundClasses: isActive
|
||||
? "text-[rgb(var(--nodedc-on-card-active-rgb))]"
|
||||
: "text-[rgb(var(--nodedc-on-card-passive-rgb))]",
|
||||
subtleTextClasses: isActive ? "text-[#2F4721]" : "text-[#B3B3B8]",
|
||||
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))]"
|
||||
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-[rgb(var(--nodedc-on-card-passive-rgb))]",
|
||||
@@ -61,7 +61,7 @@ export const NodedcWorkItemProgress = (props: TNodedcWorkItemProgressProps) => {
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 right-6 translate-x-1/2 text-center text-[8px] leading-[10px] font-medium",
|
||||
isActive ? "text-[#2F4721]" : "text-[#B3B3B8]"
|
||||
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]"
|
||||
)}
|
||||
>
|
||||
{percentage}%
|
||||
@@ -70,10 +70,16 @@ export const NodedcWorkItemProgress = (props: TNodedcWorkItemProgressProps) => {
|
||||
{segments.map((segmentFill, segmentIndex) => (
|
||||
<div
|
||||
key={segmentIndex}
|
||||
className={cn("h-full flex-1 overflow-hidden rounded-full", isActive ? "bg-black/20" : "bg-white/16")}
|
||||
className={cn(
|
||||
"h-full flex-1 overflow-hidden rounded-full",
|
||||
isActive ? "bg-[rgb(var(--nodedc-on-card-active-rgb))]/20" : "bg-white/16"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn("h-full rounded-full", isActive ? "bg-black" : "bg-[#C3FF66]")}
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
isActive ? "bg-[rgb(var(--nodedc-on-card-active-rgb))]" : "bg-[rgb(var(--nodedc-accent-rgb))]"
|
||||
)}
|
||||
style={{ width: `${segmentFill}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const PROFILE_STATE_GROUP_COLORS: Partial<Record<TStateGroups, string>> =
|
||||
backlog: "#050505",
|
||||
unstarted: "#7C7F85",
|
||||
started: "#F5F7FB",
|
||||
completed: "#C3FF66",
|
||||
completed: "rgb(var(--nodedc-accent-rgb))",
|
||||
cancelled: "#050505",
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const PROFILE_STATE_GROUP_LABELS: Partial<Record<TStateGroups, string>> =
|
||||
};
|
||||
|
||||
export const PROFILE_PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: "#C3FF66",
|
||||
urgent: "rgb(var(--nodedc-accent-rgb))",
|
||||
high: "#F5F7FB",
|
||||
medium: "#7C7F85",
|
||||
low: "#2A2B2E",
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* 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";
|
||||
// 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";
|
||||
|
||||
const ACCENT_PRESET_COLORS = [
|
||||
"#EF4444",
|
||||
"#F97316",
|
||||
"#FACC15",
|
||||
"#8B5E34",
|
||||
"#C3FF66",
|
||||
"#5FBF2A",
|
||||
"#A855F7",
|
||||
"#7C3AED",
|
||||
"#3B82F6",
|
||||
"#2DD4BF",
|
||||
"#86EFAC",
|
||||
"#050505",
|
||||
"#2A2B2E",
|
||||
"#7C7F85",
|
||||
"#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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
+3
@@ -7,6 +7,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
|
||||
// local imports
|
||||
import { ProfileSettingsAccentColor } from "./accent-color";
|
||||
|
||||
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
|
||||
return (
|
||||
@@ -18,6 +20,7 @@ export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSe
|
||||
description: "select_or_customize_your_interface_color_scheme",
|
||||
}}
|
||||
/>
|
||||
<ProfileSettingsAccentColor />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { rgbToNodedcAccentHex } from "@plane/utils";
|
||||
import designConfig from "../../design.config.json";
|
||||
|
||||
const defaultAccentRgb = designConfig.nodedc.accent_rgb as [number, number, number];
|
||||
|
||||
export const NODEDC_DEFAULT_ACCENT_HEX = rgbToNodedcAccentHex(defaultAccentRgb);
|
||||
@@ -12,7 +12,8 @@ import { useTheme } from "next-themes";
|
||||
import type { TLanguage } from "@plane/i18n";
|
||||
import { DEFAULT_LANGUAGE, useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import { applyCustomTheme, clearCustomTheme } from "@plane/utils";
|
||||
import { applyCustomTheme, applyNodedcAccent, clearCustomTheme } from "@plane/utils";
|
||||
import { NODEDC_DEFAULT_ACCENT_HEX } from "@/helpers/nodedc-design";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useRouterParams } from "@/hooks/store/use-router-params";
|
||||
@@ -107,6 +108,11 @@ function StoreWrapper(props: TStoreWrapper) {
|
||||
previousThemeRef.current = currentTheme;
|
||||
}, [userProfile?.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile?.id) return;
|
||||
applyNodedcAccent(userProfile?.theme?.nodedcAccent || NODEDC_DEFAULT_ACCENT_HEX);
|
||||
}, [userProfile?.id, userProfile?.theme?.nodedcAccent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile?.id) return;
|
||||
changeLanguage((userProfile?.language as TLanguage) || DEFAULT_LANGUAGE);
|
||||
|
||||
@@ -45,6 +45,7 @@ export class ProfileStore implements IUserProfileStore {
|
||||
primary: undefined,
|
||||
background: undefined,
|
||||
darkPalette: false,
|
||||
nodedcAccent: undefined,
|
||||
},
|
||||
onboarding_step: {
|
||||
workspace_join: false,
|
||||
|
||||
Reference in New Issue
Block a user