UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: glass-сайдбар, popup рабочей области и полировка layout
This commit is contained in:
parent
4ba06307ed
commit
b46390ccdd
|
|
@ -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 карточка не стала второй несогласованной системой учета
|
||||||
|
|
||||||
## Обязательные требования
|
## Обязательные требования
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 скрывается
|
||||||
|
|
|
||||||
|
|
@ -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 />}
|
||||||
|
|
|
||||||
|
|
@ -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", {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: "Добавить ссылку",
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue