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 список отправленных запросов
- source-side детальный экран на базе shell `Предложений` - source-side детальный экран на базе shell `Предложений`
- status pill по фактическому состоянию target issue - 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: ## Freeze point на 2026-04-19
- выбор `Внешнего контура` сейчас доступен только среди проектов, в которых отправитель уже состоит
- это осознанное упрощение первого рабочего вертикального среза Текущий этап временно заморожен.
- за счет этого source-side карточка может безопасно использовать обычные target issue API без отдельного proxy-слоя для комментариев и файлов
Заморозка делается после достижения рабочего вертикального среза:
- запрос можно отправить из проекта-источника в другой проект того же `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. потом синхронизация 3. потом синхронизация
4. потом уведомления и полировка 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. Термины и навигация ## Этап 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 - достаточно ли proxy-доступа к файлам целевой задачи
- или физически копируем файл в source representation - или файлы надо физически копировать в source-side representation
- нужно ли зеркалировать inline-файлы из описания и комментариев
### 3. Комментарии ### 5. Комментарии
Нужно решить: Нужно решить:
- комментарии зеркалируются односторонне из цели в источник - source-side reply остается облегченной обратной связью
- или источник тоже может отвечать прямо из source-side карточки - или нужен полноценный двусторонний поток комментариев как единый discussion-thread
### 4. Уровень realtime ### 6. Уровень realtime
Нужно решить: Нужно решить:
- хватит ли near-realtime через polling и existing refresh - хватает ли polling для PoC
- или сразу нужен realtime через live-события - или следующий этап уже должен включать push/realtime события
### 5. Доступ к target issue ### 7. Счетчики и вкладки
Нужно решить:
- нужен ли отдельный unread-counter по вкладкам `Открытые / Завершенные`
- нужен ли отдельный сегмент для запросов, принятых во `Внутренний контур`
### 8. Право редактирования после отправки
Нужно решить:
- отправитель редактирует только `заголовок` и `описание`
- или после отправки он может менять еще `срок`, `назначенного`, `приоритет`, `метки`
### 9. Доступ к target issue
Нужно решить: Нужно решить:
- должен ли инициатор иметь прямую ссылку на target issue - должен ли инициатор иметь прямую ссылку на target issue
- или доступ к target issue должен быть скрыт, а source-side карточка должна быть единственной точкой просмотра - или source-side карточка должна оставаться единственной точкой просмотра для пользователей без membership
Текущее решение: Текущее решение:
- при отсутствии membership в target project прямой переход в target issue скрывается - при отсутствии membership в target project прямой переход в target issue скрывается

View File

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

View File

@ -12,6 +12,7 @@ import { TopNavPowerK } from "@/components/navigation";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root"; import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root"; import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences"; import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import { AppSidebarItem } from "@/components/sidebar/sidebar-item"; import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import { InboxIcon } from "@plane/propel/icons"; import { InboxIcon } from "@plane/propel/icons";
@ -26,6 +27,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
// store hooks // store hooks
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications(); const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { preferences } = useAppRailPreferences(); const { preferences } = useAppRailPreferences();
const { isMobile } = usePlatformOS();
const showLabel = preferences.displayMode === "icon_with_label"; const showLabel = preferences.displayMode === "icon_with_label";
@ -41,6 +43,8 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
? unreadNotificationsCount.mention_unread_notifications_count ? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count; : unreadNotificationsCount.total_unread_notifications_count;
if (!isMobile) return null;
return ( return (
<div <div
className={cn("z-[27] flex min-h-10 w-full items-center bg-canvas px-3.5 transition-all duration-300", { 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; children: ReactNode;
appendElement?: ReactNode; appendElement?: ReactNode;
childrenClassName?: string; childrenClassName?: string;
className?: string;
labelClassName?: string;
}; };
export function SidebarPropertyListItem(props: TSidebarPropertyListItemProps) { export function SidebarPropertyListItem(props: TSidebarPropertyListItemProps) {
const { icon: Icon, label, children, appendElement, childrenClassName } = props; const { icon: Icon, label, children, appendElement, childrenClassName, className, labelClassName } = props;
return ( return (
<div className="flex items-start gap-2"> <div className={cn("grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-start gap-x-6", className)}>
<div className="flex h-7.5 w-30 shrink-0 items-center gap-1.5 text-body-xs-regular text-tertiary"> <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" /> <Icon className="size-4 shrink-0" />
<span>{label}</span> <span className="min-w-0 break-words">{label}</span>
{appendElement} {appendElement}
</div> </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> </div>
); );
} }

View File

@ -12,8 +12,7 @@ import { Combobox } from "@headlessui/react";
// plane imports // plane imports
import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants"; import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button"; import { CheckIcon, SearchIcon } from "@plane/propel/icons";
import { CheckIcon, SearchIcon, PlusIcon } from "@plane/propel/icons";
import type { IIssueLabel } from "@plane/types"; import type { IIssueLabel } from "@plane/types";
import { EUserProjectRoles } from "@plane/types"; import { EUserProjectRoles } from "@plane/types";
// helpers // helpers
@ -92,7 +91,7 @@ export const IssueLabelSelect = observer(function IssueLabelSelect(props: IIssue
const issueLabels = values ?? []; 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>) => { const searchInputKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") { if (query !== "" && e.key === "Escape") {
@ -121,22 +120,20 @@ export const IssueLabelSelect = observer(function IssueLabelSelect(props: IIssue
<> <>
<Combobox <Combobox
as="div" as="div"
className="size-full flex-shrink-0 text-left" className="inline-flex max-w-full flex-shrink-0 text-left"
value={issueLabels} value={issueLabels}
onChange={(value) => onSelect(value)} onChange={(value) => onSelect(value)}
multiple multiple
> >
<Combobox.Button as={Fragment}> <Combobox.Button as={Fragment}>
<Button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
variant="tertiary" 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"
size="sm"
prependIcon={<PlusIcon />}
onClick={() => !projectLabels && fetchLabels()} onClick={() => !projectLabels && fetchLabels()}
> >
{label} {label}
</Button> </button>
</Combobox.Button> </Combobox.Button>
<Combobox.Options className="fixed z-10"> <Combobox.Options className="fixed z-10">

View File

@ -121,7 +121,7 @@ export const IssueParentSelect = observer(function IssueParentSelect(props: TIss
)} )}
</div> </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 && ( {!disabled && (
<span <span

View File

@ -285,7 +285,7 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
className={cn( className={cn(
"relative h-full min-h-[120px] transition-all", "relative h-full min-h-[120px] transition-all",
{ "rounded-sm bg-layer-1": isDraggingOverColumn }, { "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} ref={columnRef}
> >

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * 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 { observer } from "mobx-react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
// plane imports // plane imports
@ -27,6 +27,8 @@ import { PeekOverviewIssueDetails } from "./issue-detail";
import { IssuePeekOverviewLoader } from "./loader"; import { IssuePeekOverviewLoader } from "./loader";
import { PeekOverviewProperties } from "./properties"; import { PeekOverviewProperties } from "./properties";
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
interface IIssueView { interface IIssueView {
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
@ -60,9 +62,21 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false); const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);
const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false); const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = 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 // ref
const issuePeekOverviewRef = useRef<HTMLDivElement>(null); const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorRefApi>(null); const editorRef = useRef<EditorRefApi>(null);
const initialPeekWidthRef = useRef<number>(0);
const initialMouseXRef = useRef<number>(0);
// store hooks // store hooks
const { const {
setPeekIssue, setPeekIssue,
@ -85,6 +99,77 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const isAnyLocalModalOpen = const isAnyLocalModalOpen =
isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen; 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( usePeekOverviewOutsideClickDetector(
issuePeekOverviewRef, 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" ? "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`, : `h-full w-full`,
!embedIssue && { !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", peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal", "top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen", "absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
@ -141,10 +226,19 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
ref={issuePeekOverviewRef} ref={issuePeekOverviewRef}
className={peekOverviewIssueClassName} className={peekOverviewIssueClassName}
style={{ style={{
width: !embedIssue && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
boxShadow: 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)", "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 ? ( {isError ? (
<div className="relative h-screen w-full overflow-hidden"> <div className="relative h-screen w-full overflow-hidden">
<IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} /> <IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} />

View File

@ -21,7 +21,12 @@ import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search"; 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 // router
const router = useAppRouter(); const router = useAppRouter();
const params = useParams(); const params = useParams();
@ -208,6 +213,7 @@ export const TopNavPowerK = observer(() => {
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
{variant === "top-navigation" ? (
<div <div
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", { className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen, "w-[554px]": isOpen,
@ -245,12 +251,73 @@ export const TopNavPowerK = observer(() => {
)} )}
</div> </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 <div
className={cn( 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", "flex h-8 w-full items-center overflow-hidden rounded-full transition-all duration-300",
{ {
"max-h-[80vh] w-[574px] opacity-100": isOpen, "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(
"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 && variant === "top-navigation",
"max-h-[70vh] w-[20rem] opacity-100": isOpen && variant === "sidebar",
"h-0 w-0 opacity-0": !isOpen, "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 <div
id="main-sidebar" id="main-sidebar"
className={cn( 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", !isResizing && "transition-all duration-300 ease-in-out",
isCollapsed ? "w-0 translate-x-[-100%] opacity-0" : "translate-x-0 opacity-100", isCollapsed ? "w-0 translate-x-[-100%] opacity-0" : "translate-x-0 opacity-100",
isMobile && "absolute", isMobile && "absolute",
@ -197,7 +197,7 @@ export function ResizableSidebar({
> >
<aside <aside
className={cn( 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" isAnyExtendedSidebarExpanded && "rounded-none"
)} )}
> >

View File

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

View File

@ -46,12 +46,12 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
<Menu.Item <Menu.Item
as="div" as="div"
className={cn("px-4 py-2", { className={cn("px-4 py-2", {
"bg-layer-transparent-active": workspace.id === activeWorkspace?.id, "bg-white/[0.03]": workspace.id === activeWorkspace?.id,
"hover:bg-layer-transparent-hover": 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="flex items-center justify-between gap-2 rounded-[1rem] p-1.5 text-13 text-primary">
<div className="relative flex w-[80%] items-center justify-start gap-2.5"> <div className="relative flex w-[82%] items-center justify-start gap-2.5">
<span <span
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center border-subtle p-2 text-14 font-medium uppercase ${ 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" !workspace?.logo_url && "rounded-md bg-[#026292] text-on-color"
@ -90,7 +90,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
</div> </div>
{workspace.id === activeWorkspace?.id && ( {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) && ( {[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
<Link <Link
href={`/${workspace.slug}/settings`} href={`/${workspace.slug}/settings`}
@ -98,7 +98,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
e.stopPropagation(); e.stopPropagation();
handleClose(); 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" /> <Settings className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span> <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(); e.stopPropagation();
handleClose(); 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" /> <UserPlus className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap"> <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 { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker"; 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 { IconButton } from "@plane/propel/icon-button";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui"; 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") : t("aria_labels.projects_sidebar.open_project_menu")
} }
> >
<div className="grid size-4 flex-shrink-0 place-items-center"> <div className="grid size-8 flex-shrink-0 place-items-center rounded-full border border-white/8 bg-white/[0.04]">
<Logo logo={project.logo_props} size={16} /> <WorkItemsIcon className="size-4 text-tertiary" />
</div> </div>
<p className="truncate text-13 font-medium text-secondary">{project.name}</p> <p className="truncate text-13 font-medium text-secondary">{project.name}</p>
</Disclosure.Button> </Disclosure.Button>
) : ( ) : (
<div className="flex w-full flex-grow items-center gap-1.5 text-left select-none"> <div className="flex w-full flex-grow items-center gap-3 text-left select-none">
<div className="grid size-4 flex-shrink-0 place-items-center"> <div className="grid size-8 flex-shrink-0 place-items-center rounded-full border border-white/8 bg-white/[0.04]">
<Logo logo={project.logo_props} size={16} /> <WorkItemsIcon className="size-4 text-tertiary" />
</div> </div>
<p className="truncate text-13 font-medium text-secondary">{project.name}</p> <p className="truncate text-13 font-medium text-secondary">{project.name}</p>
</div> </div>

View File

@ -70,8 +70,10 @@ export const SidebarItemBase = observer(function SidebarItemBase({
return ( return (
<Link href={itemHref} onClick={handleLinkClick}> <Link href={itemHref} onClick={handleLinkClick}>
<SidebarNavItem isActive={item.highlight(pathname, itemHref)}> <SidebarNavItem isActive={item.highlight(pathname, itemHref)}>
<div className="flex items-center gap-1.5 py-[1px]"> <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} {icon}
</div>
<p className="text-13 leading-5 font-medium">{t(item.labelTranslationKey)}</p> <p className="text-13 leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div> </div>
{additionalRender?.(item.key, slug)} {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 { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUser } from "@/hooks/store/user"; 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 // states
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
// router // router
@ -57,6 +62,19 @@ export const UserMenuRoot = observer(function UserMenuRoot() {
<CustomMenu <CustomMenu
className="flex items-center" className="flex items-center"
customButton={ customButton={
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 <AppSidebarItem
variant="button" variant="button"
item={{ item={{
@ -71,6 +89,7 @@ export const UserMenuRoot = observer(function UserMenuRoot() {
isActive: isUserMenuOpen, isActive: isUserMenuOpen,
}} }}
/> />
)
} }
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)} menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
onMenuClose={() => setIsUserMenuOpen(false)} onMenuClose={() => setIsUserMenuOpen(false)}

View File

@ -30,7 +30,7 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item"; import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = { type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation"; variant: "sidebar" | "top-navigation" | "sidebar-panel";
}; };
export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: WorkspaceMenuRootProps) { export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: WorkspaceMenuRootProps) {
@ -77,9 +77,10 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
return ( return (
<Menu <Menu
as="div" 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", "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 }) => { {({ open, close }: { open: boolean; close: () => void }) => {
@ -135,6 +136,35 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/> />
</Menu.Button> </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 <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
@ -147,15 +177,25 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Menu.Items as={Fragment}> <Menu.Items as={Fragment}>
<div <div
className={cn( 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-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation", "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"> <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} {currentUser?.email}
</span> </span>
{workspacesList ? ( {workspacesList ? (
@ -191,7 +231,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Link href="/create-workspace" className="w-full"> <Link href="/create-workspace" className="w-full">
<Menu.Item <Menu.Item
as="div" 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" /> <CirclePlus className="size-4 flex-shrink-0" />
{t("create_workspace")} {t("create_workspace")}
@ -202,7 +242,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Link href="/invitations" className="w-full" onClick={handleItemClick}> <Link href="/invitations" className="w-full" onClick={handleItemClick}>
<Menu.Item <Menu.Item
as="div" 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" /> <Mails className="h-4 w-4 flex-shrink-0" />
{t("workspace_invites")} {t("workspace_invites")}
@ -213,7 +253,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<Menu.Item <Menu.Item
as="button" as="button"
type="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} onClick={handleSignOut}
> >
<LogOut className="size-4 flex-shrink-0" /> <LogOut className="size-4 flex-shrink-0" />

View File

@ -45,6 +45,22 @@ export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
// Outside click detection // Outside click detection
useOutsideClickDetector(containerRef, handleOutsideClick); 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) // Track keyboard shortcuts that trigger focus (Cmd+F / Ctrl+F)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {

View File

@ -198,3 +198,58 @@
0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important; 0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important;
will-change: transform, opacity; 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", assignee: "Add assignees",
start_date: "Add start date", start_date: "Add start date",
due_date: "Add due date", due_date: "Add due date",
parent: "Add parent work item", parent: "Add parent item",
sub_issue: "Add sub-work item", sub_issue: "Add sub-work item",
relation: "Add relation", relation: "Add relation",
link: "Add link", link: "Add link",

View File

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

View File

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

View File

@ -41,8 +41,9 @@ export interface DialogTitleProps extends React.ComponentProps<typeof BaseDialog
} }
// Constants // Constants
const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-backdrop"); const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-backdrop/70 backdrop-blur-sm");
const BASE_CLASSNAME = "relative text-left bg-surface-1 rounded-lg shadow-md w-full z-100 border border-subtle"; 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 // Utility functions
const getPositionClassNames = (position: DialogPosition) => const getPositionClassNames = (position: DialogPosition) =>

View File

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

View File

@ -84,7 +84,7 @@ export function ModalPortal({
const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position]; const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position];
return cn( 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, widthClass,
positionClass, positionClass,
contentClassName contentClassName
@ -101,7 +101,7 @@ export function ModalPortal({
> >
{showOverlay && ( {showOverlay && (
<div <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} onClick={handleOverlayClick}
aria-hidden="true" aria-hidden="true"
/> />

View File

@ -41,7 +41,7 @@ export function ModalCore(props: Props) {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" 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> </Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto"> <div className="fixed inset-0 z-30 overflow-y-auto">
@ -57,7 +57,7 @@ export function ModalCore(props: Props) {
> >
<Dialog.Panel <Dialog.Panel
className={cn( 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, width,
className className
)} )}