UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: glass-сайдбар, popup рабочей области и полировка layout

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 17:23:12 +03:00
parent 4ba06307ed
commit b46390ccdd
27 changed files with 589 additions and 133 deletions

View File

@ -81,12 +81,56 @@
- source-side список отправленных запросов
- source-side детальный экран на базе shell `Предложений`
- status pill по фактическому состоянию target issue
- чтение и редактирование title/description/priority/due date/assignees/labels через target issue API
- workspace-wide выбор целевого внешнего контура по policy:
- тот же `workspace`
- у целевого проекта включен модуль
- прямой membership в target project не требуется
- source-side карточка как основная точка работы отправителя, если у него нет доступа в целевой проект
- source-side редактирование открытого запроса:
- `заголовок`
- `описание`
- source-side действия:
- `Принять`
- `Отклонить`
- `Ответ во внешний контур`
- зеркалирование из целевой задачи в source-side карточку:
- комментарии
- вложения
- activity
- обновления статуса
- proxy download для вложений без прямого membership в target project
- unread-индикатор новых изменений по source-side карточке
- блок `Маршрутизация` в фиксированном формате `3 x 3`
Текущее ограничение MVP:
- выбор `Внешнего контура` сейчас доступен только среди проектов, в которых отправитель уже состоит
- это осознанное упрощение первого рабочего вертикального среза
- за счет этого source-side карточка может безопасно использовать обычные target issue API без отдельного proxy-слоя для комментариев и файлов
## Freeze point на 2026-04-19
Текущий этап временно заморожен.
Заморозка делается после достижения рабочего вертикального среза:
- запрос можно отправить из проекта-источника в другой проект того же `workspace`
- в целевом проекте создается обычная задача и сразу попадает в обычный workflow
- отправитель видит свой source-side список и карточку без обязательного доступа в чужой проект
- в source-side карточке видны статус, маршрут, назначенный, срок, комментарии, вложения и activity из целевого контура
- отправитель может поправить ошибку в `заголовке` и `описании`, пока запрос открыт
- отправитель может принять результат во внутренний контур или вернуть запрос обратно во внешний контур с комментарием причины
На этом месте разработка следующего шага останавливается до фиксации продуктовых решений.
## Что нужно решить перед продолжением
- Что именно делает действие `Принять`:
- только фиксирует решение источника
- создает отдельную сущность во `Внутреннем контуре`
- или переводит внешний запрос в отдельный внутренний режим без дублирования сущностей
- Должен ли принятый запрос оставаться во `Внешних контурах` как историческая карточка, или он должен исчезать из рабочего списка и жить только во `Внутреннем контуре`
- Нужен ли отдельный статус или отдельная вкладка для запросов, которые уже приняты во `Внутренний контур`
- Должна ли коммуникация по комментариям быть полностью двусторонней, или source-side ответов достаточно только для возврата и уточнений
- Нужно ли физически копировать файлы в source-side представление, или для PoC достаточно proxy-доступа к файлам целевой задачи
- Нужно ли переводить обновление карточки с polling на realtime/push уже в следующем этапе, или polling пока приемлем
- Нужны ли отдельные счетчики непрочитанных изменений по вкладкам `Открытые` и `Завершенные`
- Должен ли отправитель после создания запроса иметь право менять только `заголовок` и `описание`, или еще и `назначенного`, `срок`, `приоритет`, `метки`
- Нужно ли сохранять запрет на прямой переход в целевую задачу для пользователей без membership в target project как постоянное правило
- Какой финальный lifecycle должен быть у запроса после возврата, принятия, завершения и отмены, чтобы source-side карточка не стала второй несогласованной системой учета
## Обязательные требования

View File

@ -10,6 +10,23 @@
3. потом синхронизация
4. потом уведомления и полировка
## Freeze point на 2026-04-19
Текущий вертикальный срез временно заморожен.
На момент freeze point уже есть:
- отправка запроса из source project в target project того же `workspace`
- отсутствие требования прямого membership в target project для отправки
- source-side список `Открытые / Завершенные`
- source-side карточка на shell `Предложений`
- source-side редактирование открытого запроса по `заголовку` и `описанию`
- зеркалирование статуса, комментариев, вложений и activity из целевого контура
- source-side действия `Принять`, `Отклонить`, `Ответ во внешний контур`
- индикатор непрочитанных изменений
- карточка `Маршрутизация` в целевом формате `3 x 3`
Дальше по roadmap пока не идем, пока не приняты продуктовые решения по внутреннему жизненному циклу принятого запроса.
## Этап 0. Термины и навигация
### Цель
@ -242,35 +259,64 @@
## Открытые вопросы
### 1. Source-side сущность
### 1. Что именно делает `Принять`
Нужно зафиксировать конечную бизнес-логику:
- `Принять` только ставит source-side решение `accepted`
- `Принять` создает отдельную сущность во `Внутреннем контуре`
- `Принять` переводит карточку в отдельный внутренний статус без создания дубликата
Это главный блокирующий вопрос перед следующим этапом.
### 2. Где дальше живет принятый запрос
Нужно решить:
- карточка остается во `Внешних контурах` как историческая запись
- карточка уходит во `Внутренний контур`
- карточка одновременно видна в обоих местах, но с разной ролью
### 3. Нужна ли отдельная сущность source-side
Нужно принять решение:
- достаточно ли source-side проекции
- или нужна отдельная таблица/модель для внешних контуров
- достаточно ли текущей source-side проекции поверх bridge metadata
- или уже пора вводить отдельную таблицу/модель для внешних контуров
### 2. Файлы
### 4. Файлы
Нужно решить:
- показываем ли мы source-side ссылку на target asset
- или физически копируем файл в source representation
- достаточно ли proxy-доступа к файлам целевой задачи
- или файлы надо физически копировать в source-side representation
- нужно ли зеркалировать inline-файлы из описания и комментариев
### 3. Комментарии
### 5. Комментарии
Нужно решить:
- комментарии зеркалируются односторонне из цели в источник
- или источник тоже может отвечать прямо из source-side карточки
- source-side reply остается облегченной обратной связью
- или нужен полноценный двусторонний поток комментариев как единый discussion-thread
### 4. Уровень realtime
### 6. Уровень realtime
Нужно решить:
- хватит ли near-realtime через polling и existing refresh
- или сразу нужен realtime через live-события
- хватает ли polling для PoC
- или следующий этап уже должен включать push/realtime события
### 5. Доступ к target issue
### 7. Счетчики и вкладки
Нужно решить:
- нужен ли отдельный unread-counter по вкладкам `Открытые / Завершенные`
- нужен ли отдельный сегмент для запросов, принятых во `Внутренний контур`
### 8. Право редактирования после отправки
Нужно решить:
- отправитель редактирует только `заголовок` и `описание`
- или после отправки он может менять еще `срок`, `назначенного`, `приоритет`, `метки`
### 9. Доступ к target issue
Нужно решить:
- должен ли инициатор иметь прямую ссылку на target issue
- или доступ к target issue должен быть скрыт, а source-side карточка должна быть единственной точкой просмотра
- или source-side карточка должна оставаться единственной точкой просмотра для пользователей без membership
Текущее решение:
- при отсутствии membership в target project прямой переход в target issue скрывается

View File

@ -8,13 +8,14 @@ import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
// plane helpers
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper";
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list";
import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions";
import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items";
import { SidebarUtilityRail } from "@/components/workspace/sidebar/sidebar-utility-rail";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
// hooks
import { useFavorite } from "@/hooks/store/use-favorite";
import { useUserPermissions } from "@/hooks/store/user";
@ -22,7 +23,6 @@ import { useUserPermissions } from "@/hooks/store/user";
import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list";
export const AppSidebar = observer(function AppSidebar() {
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
const { groupedFavorites } = useFavorite();
@ -36,7 +36,11 @@ export const AppSidebar = observer(function AppSidebar() {
const isFavoriteEmpty = isEmpty(groupedFavorites);
return (
<SidebarWrapper title={t("projects")} quickActions={<SidebarQuickActions />}>
<SidebarWrapper
header={<WorkspaceMenuRoot variant="sidebar-panel" />}
quickActions={<SidebarQuickActions />}
footer={<SidebarUtilityRail />}
>
<SidebarMenuItems />
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}

View File

@ -12,6 +12,7 @@ import { TopNavPowerK } from "@/components/navigation";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { Tooltip } from "@plane/propel/tooltip";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { InboxIcon } from "@plane/propel/icons";
@ -26,6 +27,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
// store hooks
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { preferences } = useAppRailPreferences();
const { isMobile } = usePlatformOS();
const showLabel = preferences.displayMode === "icon_with_label";
@ -41,6 +43,8 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
if (!isMobile) return null;
return (
<div
className={cn("z-[27] flex min-h-10 w-full items-center bg-canvas px-3.5 transition-all duration-300", {

View File

@ -13,19 +13,28 @@ type TSidebarPropertyListItemProps = {
children: ReactNode;
appendElement?: ReactNode;
childrenClassName?: string;
className?: string;
labelClassName?: string;
};
export function SidebarPropertyListItem(props: TSidebarPropertyListItemProps) {
const { icon: Icon, label, children, appendElement, childrenClassName } = props;
const { icon: Icon, label, children, appendElement, childrenClassName, className, labelClassName } = props;
return (
<div className="flex items-start gap-2">
<div className="flex h-7.5 w-30 shrink-0 items-center gap-1.5 text-body-xs-regular text-tertiary">
<div className={cn("grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-start gap-x-6", className)}>
<div
className={cn(
"flex min-h-7.5 min-w-0 items-start gap-1.5 text-body-xs-regular text-tertiary",
labelClassName
)}
>
<Icon className="size-4 shrink-0" />
<span>{label}</span>
<span className="min-w-0 break-words">{label}</span>
{appendElement}
</div>
<div className={cn("flex grow flex-wrap items-center gap-1", childrenClassName)}>{children}</div>
<div className={cn("flex min-w-0 flex-wrap items-center gap-1 justify-self-stretch", childrenClassName)}>
{children}
</div>
</div>
);
}

View File

@ -12,8 +12,7 @@ import { Combobox } from "@headlessui/react";
// plane imports
import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { CheckIcon, SearchIcon, PlusIcon } from "@plane/propel/icons";
import { CheckIcon, SearchIcon } from "@plane/propel/icons";
import type { IIssueLabel } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
// helpers
@ -92,7 +91,7 @@ export const IssueLabelSelect = observer(function IssueLabelSelect(props: IIssue
const issueLabels = values ?? [];
const label = <span className="text-body-xs-medium text-placeholder">{t("label.select")}</span>;
const label = <span className="text-body-xs-medium text-placeholder whitespace-nowrap">{t("label.select")}</span>;
const searchInputKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
@ -121,22 +120,20 @@ export const IssueLabelSelect = observer(function IssueLabelSelect(props: IIssue
<>
<Combobox
as="div"
className="size-full flex-shrink-0 text-left"
className="inline-flex max-w-full flex-shrink-0 text-left"
value={issueLabels}
onChange={(value) => onSelect(value)}
multiple
>
<Combobox.Button as={Fragment}>
<Button
<button
ref={setReferenceElement}
type="button"
variant="tertiary"
size="sm"
prependIcon={<PlusIcon />}
className="flex h-7.5 items-center rounded-sm border-0 bg-transparent py-0.5 pl-0 pr-2 text-body-xs-medium text-placeholder whitespace-nowrap outline-none hover:bg-layer-transparent-hover active:bg-layer-transparent-active"
onClick={() => !projectLabels && fetchLabels()}
>
{label}
</Button>
</button>
</Combobox.Button>
<Combobox.Options className="fixed z-10">

View File

@ -121,7 +121,7 @@ export const IssueParentSelect = observer(function IssueParentSelect(props: TIss
)}
</div>
) : (
<span className="text-body-xs-medium text-placeholder">{t("issue.add.parent")}</span>
<span className="text-body-xs-medium text-placeholder whitespace-nowrap">{t("issue.add.parent")}</span>
)}
{!disabled && (
<span

View File

@ -285,7 +285,7 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
className={cn(
"relative h-full min-h-[120px] transition-all",
{ "rounded-sm bg-layer-1": isDraggingOverColumn },
{ "vertical-scrollbar scrollbar-md": !sub_group_by && !shouldOverlayBeVisible }
{ "vertical-scrollbar scrollbar-md -mr-2 pr-2": !sub_group_by && !shouldOverlayBeVisible }
)}
ref={columnRef}
>

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/
import { useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
// plane imports
@ -27,6 +27,8 @@ import { PeekOverviewIssueDetails } from "./issue-detail";
import { IssuePeekOverviewLoader } from "./loader";
import { PeekOverviewProperties } from "./properties";
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
interface IIssueView {
workspaceSlug: string;
projectId: string;
@ -60,9 +62,21 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);
const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
if (typeof window === "undefined") return 720;
const fallbackWidth = Math.max(640, Math.floor(window.innerWidth * 0.5));
const storedWidth = window.localStorage.getItem(SIDE_PEEK_WIDTH_STORAGE_KEY);
const parsedWidth = storedWidth ? parseInt(storedWidth, 10) : NaN;
return Number.isFinite(parsedWidth) ? parsedWidth : fallbackWidth;
});
const [isResizingPeek, setIsResizingPeek] = useState(false);
// ref
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorRefApi>(null);
const initialPeekWidthRef = useRef<number>(0);
const initialMouseXRef = useRef<number>(0);
// store hooks
const {
setPeekIssue,
@ -85,6 +99,77 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const isAnyLocalModalOpen =
isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen;
const stopPeekResizing = useCallback(() => {
setIsResizingPeek(false);
}, []);
const handlePeekResize = useCallback(
(event: MouseEvent) => {
if (!isResizingPeek) return;
const maxWidth = Math.max(720, window.innerWidth - 48);
const minWidth = 640;
const deltaX = event.clientX - initialMouseXRef.current;
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
setSidePeekWidth(nextWidth);
},
[isResizingPeek]
);
const startPeekResizing = useCallback(
(event: ReactMouseEvent) => {
if (peekMode !== "side-peek") return;
event.preventDefault();
setIsResizingPeek(true);
initialPeekWidthRef.current = sidePeekWidth;
initialMouseXRef.current = event.clientX;
},
[peekMode, sidePeekWidth]
);
useEffect(() => {
const handleWindowResize = () => {
const maxWidth = Math.max(720, window.innerWidth - 48);
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
};
window.addEventListener("resize", handleWindowResize);
return () => window.removeEventListener("resize", handleWindowResize);
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
const maxWidth = Math.max(720, window.innerWidth - 48);
const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth);
window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth));
if (clampedWidth !== sidePeekWidth) {
setSidePeekWidth(clampedWidth);
}
}, [sidePeekWidth]);
useEffect(() => {
if (!isResizingPeek) return;
document.addEventListener("mousemove", handlePeekResize);
document.addEventListener("mouseup", stopPeekResizing);
document.addEventListener("mouseleave", stopPeekResizing);
window.addEventListener("blur", stopPeekResizing);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
return () => {
document.removeEventListener("mousemove", handlePeekResize);
document.removeEventListener("mouseup", stopPeekResizing);
document.removeEventListener("mouseleave", stopPeekResizing);
window.removeEventListener("blur", stopPeekResizing);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [handlePeekResize, isResizingPeek, stopPeekResizing]);
usePeekOverviewOutsideClickDetector(
issuePeekOverviewRef,
() => {
@ -123,7 +208,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: `h-full w-full`,
!embedIssue && {
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] resize-x rounded-[28px] border md:w-[50%] md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
@ -141,10 +226,19 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
ref={issuePeekOverviewRef}
className={peekOverviewIssueClassName}
style={{
width: !embedIssue && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
boxShadow:
"0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)",
}}
>
{!embedIssue && peekMode === "side-peek" && (
<div
className="absolute top-0 left-0 z-[26] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
onMouseDown={startPeekResizing}
role="separator"
aria-label="Resize issue panel"
/>
)}
{isError ? (
<div className="relative h-screen w-full overflow-hidden">
<IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} />

View File

@ -21,7 +21,12 @@ import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search";
export const TopNavPowerK = observer(() => {
type TTopNavPowerKProps = {
variant?: "top-navigation" | "sidebar";
};
export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const { variant = "top-navigation" } = props;
// router
const router = useAppRouter();
const params = useParams();
@ -208,49 +213,111 @@ export const TopNavPowerK = observer(() => {
return (
<div ref={containerRef} className="relative">
<div
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen,
})}
>
{variant === "top-navigation" ? (
<div
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
{
"bg-layer-1": isOpen,
}
)}
onClick={() => inputRef.current?.focus()}
role="button"
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen,
})}
>
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Search commands..."
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
<div
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
{
"bg-layer-1": isOpen,
}
)}
onClick={() => inputRef.current?.focus()}
role="button"
>
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Search commands..."
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
</div>
</div>
</div>
) : (
<div
className={cn("relative z-30 h-8 transition-all duration-300 ease-in-out", {
"w-[19.5rem]": isOpen,
"w-8": !isOpen,
})}
>
<button
type="button"
className="absolute left-0 top-0 z-10 flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
onClick={() => {
if (isOpen) {
closePanel();
return;
}
openPanel();
requestAnimationFrame(() => inputRef.current?.focus());
}}
aria-label="Поиск"
>
<SearchIcon className="size-3.5 shrink-0 text-placeholder" />
</button>
<div
className={cn(
"flex h-8 w-full items-center overflow-hidden rounded-full transition-all duration-300",
{
"nodedc-glass-surface pl-10 pr-3": isOpen,
"border-transparent bg-transparent pl-0 pr-0 shadow-none": !isOpen,
}
)}
>
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="Search commands..."
className={cn(
"placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none transition-all",
{
"pointer-events-none w-0 opacity-0": !isOpen,
}
)}
/>
{isOpen && searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
</div>
</div>
)}
<div
className={cn(
"shadow-lg absolute -top-[6px] left-1/2 z-20 flex -translate-x-1/2 flex-col overflow-hidden rounded-md border border-subtle bg-surface-1 px-0 pt-10 transition-all duration-300 ease-in-out",
"absolute z-20 flex flex-col overflow-hidden px-0 transition-all duration-300 ease-in-out",
{
"max-h-[80vh] w-[574px] opacity-100": isOpen,
"max-h-[80vh] w-[574px] opacity-100": isOpen && variant === "top-navigation",
"max-h-[70vh] w-[20rem] opacity-100": isOpen && variant === "sidebar",
"h-0 w-0 opacity-0": !isOpen,
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-surface bottom-11 left-0 rounded-[1.5rem] pt-3": variant === "sidebar",
}
)}
>

View File

@ -180,7 +180,7 @@ export function ResizableSidebar({
<div
id="main-sidebar"
className={cn(
"z-20 h-full border-r border-subtle bg-surface-1",
"z-20 h-full border-r border-subtle bg-transparent",
!isResizing && "transition-all duration-300 ease-in-out",
isCollapsed ? "w-0 translate-x-[-100%] opacity-0" : "translate-x-0 opacity-100",
isMobile && "absolute",
@ -197,7 +197,7 @@ export function ResizableSidebar({
>
<aside
className={cn(
"group/sidebar relative flex h-full w-full flex-col overflow-hidden bg-surface-1 pt-3",
"group/sidebar relative flex h-full w-full flex-col overflow-visible bg-transparent",
isAnyExtendedSidebarExpanded && "rounded-none"
)}
>

View File

@ -19,13 +19,15 @@ import { AppSidebarToggleButton } from "./sidebar-toggle-button";
import { IconButton } from "@plane/propel/icon-button";
type TSidebarWrapperProps = {
title: string;
title?: string;
header?: React.ReactNode;
children: React.ReactNode;
quickActions?: React.ReactNode;
footer?: React.ReactNode;
};
export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWrapperProps) {
const { title, children, quickActions } = props;
const { title, header, children, quickActions, footer } = props;
// state
const [isCustomizeNavDialogOpen, setIsCustomizeNavDialogOpen] = useState(false);
// store hooks
@ -48,14 +50,12 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
return (
<>
<CustomizeNavigationDialog isOpen={isCustomizeNavDialogOpen} onClose={() => setIsCustomizeNavDialogOpen(false)} />
<div ref={ref} className="flex h-full w-full animate-fade-in flex-col">
<div className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
<div ref={ref} className="nodedc-glass-sidebar flex h-full w-full animate-fade-in flex-col">
<div className="flex flex-col gap-3 px-3 pt-3">
<div className="flex items-center justify-between gap-2 px-2">
<span className="pt-1 text-16 font-medium text-primary">{title}</span>
<div className="min-w-0 flex-1">{header ?? <span className="pt-1 text-16 font-medium text-primary">{title}</span>}</div>
<div className="flex items-center gap-2">
{title === "Projects" && (
{title === "Projects" && !header && (
<IconButton
size="base"
variant="ghost"
@ -79,6 +79,7 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
>
{children}
</ScrollArea>
{footer && <div className="px-3 pt-2 pb-3">{footer}</div>}
</div>
</>
);

View File

@ -46,12 +46,12 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
<Menu.Item
as="div"
className={cn("px-4 py-2", {
"bg-layer-transparent-active": workspace.id === activeWorkspace?.id,
"hover:bg-layer-transparent-hover": workspace.id !== activeWorkspace?.id,
"bg-white/[0.03]": workspace.id === activeWorkspace?.id,
"hover:bg-white/[0.05]": workspace.id !== activeWorkspace?.id,
})}
>
<div className="flex items-center justify-between gap-1 rounded-sm p-1 text-13 text-primary">
<div className="relative flex w-[80%] items-center justify-start gap-2.5">
<div className="flex items-center justify-between gap-2 rounded-[1rem] p-1.5 text-13 text-primary">
<div className="relative flex w-[82%] items-center justify-start gap-2.5">
<span
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center border-subtle p-2 text-14 font-medium uppercase ${
!workspace?.logo_url && "rounded-md bg-[#026292] text-on-color"
@ -90,7 +90,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
</div>
{workspace.id === activeWorkspace?.id && (
<>
<div className="mt-2 mb-1 flex gap-2">
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
<Link
href={`/${workspace.slug}/settings`}
@ -98,7 +98,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
e.stopPropagation();
handleClose();
}}
className="flex gap-1.5 rounded-md border border-strong bg-layer-2 px-2.5 py-1.5 text-secondary transition-colors hover:border-strong hover:text-secondary hover:shadow-raised-100"
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Settings className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span>
@ -111,7 +111,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
e.stopPropagation();
handleClose();
}}
className="flex gap-1.5 rounded-md border border-strong bg-layer-2 px-2.5 py-1.5 text-secondary transition-colors hover:border-strong hover:text-secondary hover:shadow-raised-100"
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<UserPlus className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">

View File

@ -21,7 +21,7 @@ import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { LinkIcon, ArchiveIcon, ChevronRightIcon } from "@plane/propel/icons";
import { LinkIcon, ArchiveIcon, ChevronRightIcon, WorkItemsIcon } from "@plane/propel/icons";
import { IconButton } from "@plane/propel/icon-button";
import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
@ -337,15 +337,15 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
: t("aria_labels.projects_sidebar.open_project_menu")
}
>
<div className="grid size-4 flex-shrink-0 place-items-center">
<Logo logo={project.logo_props} size={16} />
<div className="grid size-8 flex-shrink-0 place-items-center rounded-full border border-white/8 bg-white/[0.04]">
<WorkItemsIcon className="size-4 text-tertiary" />
</div>
<p className="truncate text-13 font-medium text-secondary">{project.name}</p>
</Disclosure.Button>
) : (
<div className="flex w-full flex-grow items-center gap-1.5 text-left select-none">
<div className="grid size-4 flex-shrink-0 place-items-center">
<Logo logo={project.logo_props} size={16} />
<div className="flex w-full flex-grow items-center gap-3 text-left select-none">
<div className="grid size-8 flex-shrink-0 place-items-center rounded-full border border-white/8 bg-white/[0.04]">
<WorkItemsIcon className="size-4 text-tertiary" />
</div>
<p className="truncate text-13 font-medium text-secondary">{project.name}</p>
</div>

View File

@ -70,8 +70,10 @@ export const SidebarItemBase = observer(function SidebarItemBase({
return (
<Link href={itemHref} onClick={handleLinkClick}>
<SidebarNavItem isActive={item.highlight(pathname, itemHref)}>
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
<div className="flex items-center gap-3 py-[1px]">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-tertiary transition-all group-hover:bg-white/[0.07]">
{icon}
</div>
<p className="text-13 leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
{additionalRender?.(item.key, slug)}

View File

@ -0,0 +1,55 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { InboxIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import useSWR from "swr";
import { TopNavPowerK } from "@/components/navigation";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
export const SidebarUtilityRail = observer(function SidebarUtilityRail() {
const { workspaceSlug } = useParams();
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
useSWR(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null
);
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
const totalNotifications = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
return (
<div className="flex items-end justify-between gap-3">
<div className="flex flex-col items-start gap-2 pl-2.5">
<TopNavPowerK variant="sidebar" />
<Tooltip tooltipContent="Уведомления" position="right">
<Link
href={`/${workspaceSlug?.toString()}/notifications/`}
className="relative flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-tertiary backdrop-blur-[18px] transition-all hover:bg-white/[0.07] hover:text-primary"
>
<InboxIcon className="size-4" />
{totalNotifications > 0 && (
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
)}
<span className="sr-only">Уведомления</span>
</Link>
</Tooltip>
<Tooltip tooltipContent="Профиль" position="right">
<div>
<UserMenuRoot variant="sidebar-utility" />
</div>
</Tooltip>
</div>
</div>
);
});

View File

@ -22,7 +22,12 @@ import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUser } from "@/hooks/store/user";
export const UserMenuRoot = observer(function UserMenuRoot() {
type TUserMenuRootProps = {
variant?: "default" | "sidebar-utility";
};
export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) {
const { variant = "default" } = props;
// states
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
// router
@ -57,20 +62,34 @@ export const UserMenuRoot = observer(function UserMenuRoot() {
<CustomMenu
className="flex items-center"
customButton={
<AppSidebarItem
variant="button"
item={{
icon: (
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={20}
shape="circle"
/>
),
isActive: isUserMenuOpen,
}}
/>
variant === "sidebar-utility" ? (
<button
type="button"
className="flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
shape="circle"
/>
</button>
) : (
<AppSidebarItem
variant="button"
item={{
icon: (
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={20}
shape="circle"
/>
),
isActive: isUserMenuOpen,
}}
/>
)
}
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
onMenuClose={() => setIsUserMenuOpen(false)}

View File

@ -30,7 +30,7 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation";
variant: "sidebar" | "top-navigation" | "sidebar-panel";
};
export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: WorkspaceMenuRootProps) {
@ -77,9 +77,10 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
return (
<Menu
as="div"
className={cn("relative flex h-full w-fit max-w-48 truncate whitespace-nowrap", {
className={cn("relative z-20 flex h-full w-fit max-w-48 whitespace-nowrap overflow-visible", {
"w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch truncate text-left": variant === "top-navigation",
"flex-grow justify-stretch text-left": variant === "top-navigation",
"w-full max-w-none justify-stretch text-left": variant === "sidebar-panel",
})}
>
{({ open, close }: { open: boolean; close: () => void }) => {
@ -135,6 +136,35 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/>
</Menu.Button>
)}
{variant === "sidebar-panel" && (
<Menu.Button
className={cn(
"group/menu-button flex w-full items-center justify-between gap-2 px-0 py-1 text-left text-13 font-medium text-secondary transition-all focus:outline-none",
{
"text-primary": open,
}
)}
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
>
<div className="flex min-w-0 flex-1 items-center gap-2.5 truncate">
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="size-8 rounded-[0.9rem]"
/>
<div className="min-w-0 flex-1">
<h4 className="truncate text-14 font-medium text-primary">
{activeWorkspace?.name ?? t("loading")}
</h4>
</div>
</div>
<ChevronDownIcon
className={cn("size-4 flex-shrink-0 text-placeholder duration-300", {
"rotate-180": open,
})}
/>
</Menu.Button>
)}
<Transition
as={Fragment}
enter="transition ease-out duration-100"
@ -147,15 +177,25 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Menu.Items as={Fragment}>
<div
className={cn(
"fixed z-21 mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200 outline-none",
"z-21 mt-1 flex min-w-[30rem] origin-top-left flex-col divide-y outline-none",
{
"fixed divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200": variant !== "sidebar-panel",
"top-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-surface absolute top-full left-0 z-[140] mt-2 rounded-[1.5rem] divide-white/10": variant === "sidebar-panel",
}
)}
>
<div className="vertical-scrollbar flex scrollbar-sm max-h-96 flex-col items-start justify-start overflow-x-hidden overflow-y-scroll">
<span className="sticky top-0 z-21 h-full w-full flex-shrink-0 truncate rounded-md bg-surface-1 px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder">
<span
className={cn(
"sticky top-0 z-21 h-full w-full flex-shrink-0 truncate px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder",
{
"rounded-md bg-surface-1": variant !== "sidebar-panel",
"bg-transparent": variant === "sidebar-panel",
}
)}
>
{currentUser?.email}
</span>
{workspacesList ? (
@ -191,7 +231,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Link href="/create-workspace" className="w-full">
<Menu.Item
as="div"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
className="flex items-center gap-2 rounded-xl px-2 py-1.5 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
>
<CirclePlus className="size-4 flex-shrink-0" />
{t("create_workspace")}
@ -202,7 +242,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Link href="/invitations" className="w-full" onClick={handleItemClick}>
<Menu.Item
as="div"
className="flex items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
className="flex items-center gap-2 rounded-xl px-2 py-1.5 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
>
<Mails className="h-4 w-4 flex-shrink-0" />
{t("workspace_invites")}
@ -213,7 +253,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium text-danger-primary hover:bg-layer-transparent-hover"
className="flex w-full items-center gap-2 rounded-xl px-2 py-1.5 text-13 font-medium text-danger-primary hover:bg-layer-transparent-hover"
onClick={handleSignOut}
>
<LogOut className="size-4 flex-shrink-0" />

View File

@ -45,6 +45,22 @@ export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
// Outside click detection
useOutsideClickDetector(containerRef, handleOutsideClick);
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (containerRef.current?.contains(target)) return;
handleClose();
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [isOpen, handleClose]);
// Track keyboard shortcuts that trigger focus (Cmd+F / Ctrl+F)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {

View File

@ -198,3 +198,58 @@
0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important;
will-change: transform, opacity;
}
@layer components {
.nodedc-glass-sidebar {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(7, 7, 9, 0.84);
backdrop-filter: blur(40px);
border-right: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 18px 48px rgba(0, 0, 0, 0.26);
}
.nodedc-glass-modal {
box-shadow:
0 20px 56px rgba(0, 0, 0, 0.34),
0 4px 16px rgba(0, 0, 0, 0.18);
}
.nodedc-glass-surface {
background: rgba(11, 11, 14, 0.82);
-webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 20px 56px rgba(0, 0, 0, 0.34),
0 4px 16px rgba(0, 0, 0, 0.18);
isolation: isolate;
}
.nodedc-glass-modal [data-slot="button"],
.nodedc-glass-modal [data-slot="icon-button"] {
border: none !important;
box-shadow: none !important;
outline: none !important;
border-radius: 1.25rem !important;
backdrop-filter: blur(12px);
}
.nodedc-glass-modal [data-slot="button"] {
min-height: 2.5rem;
padding-inline: 0.95rem;
}
.nodedc-glass-modal [data-slot="icon-button"] {
min-height: 2.5rem;
min-width: 2.5rem;
}
.nodedc-glass-modal button:focus-visible,
.nodedc-glass-modal [role="button"]:focus-visible {
outline: none !important;
box-shadow: none !important;
}
}

View File

@ -973,7 +973,7 @@ export default {
assignee: "Add assignees",
start_date: "Add start date",
due_date: "Add due date",
parent: "Add parent work item",
parent: "Add parent item",
sub_issue: "Add sub-work item",
relation: "Add relation",
link: "Add link",

View File

@ -1130,7 +1130,7 @@ export default {
assignee: "Добавить ответственных",
start_date: "Добавить дату начала",
due_date: "Добавить срок выполнения",
parent: "Добавить родительский рабочий элемент",
parent: "Добавить родительский элемент",
sub_issue: "Добавить подэлемент",
relation: "Добавить связь",
link: "Добавить ссылку",

View File

@ -27,6 +27,7 @@ const Button = React.forwardRef(function Button(props: ButtonProps, ref: React.F
return (
<button
data-slot="button"
ref={ref}
type={type}
className={cn(buttonVariants({ variant, size }), className)}

View File

@ -41,8 +41,9 @@ export interface DialogTitleProps extends React.ComponentProps<typeof BaseDialog
}
// Constants
const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-backdrop");
const BASE_CLASSNAME = "relative text-left bg-surface-1 rounded-lg shadow-md w-full z-100 border border-subtle";
const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-backdrop/70 backdrop-blur-sm");
const BASE_CLASSNAME =
"nodedc-glass-modal relative w-full rounded-[28px] border border-subtle/70 bg-surface-1/78 text-left shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl z-100";
// Utility functions
const getPositionClassNames = (position: DialogPosition) =>

View File

@ -27,6 +27,7 @@ const IconButton = React.forwardRef(function IconButton(
return (
<button
data-slot="icon-button"
ref={ref}
type={type}
className={cn(iconButtonVariants({ variant, size }), className)}

View File

@ -84,7 +84,7 @@ export function ModalPortal({
const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position];
return cn(
"shadow-lg absolute top-0 h-full bg-white transition-transform duration-300 ease-out",
"nodedc-glass-modal absolute top-0 h-full rounded-[28px] border border-subtle/70 bg-surface-1/78 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl transition-transform duration-300 ease-out",
widthClass,
positionClass,
contentClassName
@ -101,7 +101,7 @@ export function ModalPortal({
>
{showOverlay && (
<div
className={cn("absolute inset-0 bg-black/50 transition-colors duration-300", overlayClassName)}
className={cn("absolute inset-0 bg-black/50 backdrop-blur-sm transition-colors duration-300", overlayClassName)}
onClick={handleOverlayClick}
aria-hidden="true"
/>

View File

@ -41,7 +41,7 @@ export function ModalCore(props: Props) {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-backdrop transition-opacity" />
<div className="fixed inset-0 bg-backdrop/70 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto">
@ -57,7 +57,7 @@ export function ModalCore(props: Props) {
>
<Dialog.Panel
className={cn(
"relative w-full transform rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all",
"nodedc-glass-modal relative w-full transform rounded-[28px] border border-subtle/70 bg-surface-1/78 text-left shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl transition-all",
width,
className
)}