UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: glass-сайдбар, popup рабочей области и полировка layout
This commit is contained in:
parent
4ba06307ed
commit
b46390ccdd
|
|
@ -81,12 +81,56 @@
|
|||
- source-side список отправленных запросов
|
||||
- source-side детальный экран на базе shell `Предложений`
|
||||
- status pill по фактическому состоянию target issue
|
||||
- чтение и редактирование title/description/priority/due date/assignees/labels через target issue API
|
||||
- workspace-wide выбор целевого внешнего контура по policy:
|
||||
- тот же `workspace`
|
||||
- у целевого проекта включен модуль
|
||||
- прямой membership в target project не требуется
|
||||
- source-side карточка как основная точка работы отправителя, если у него нет доступа в целевой проект
|
||||
- source-side редактирование открытого запроса:
|
||||
- `заголовок`
|
||||
- `описание`
|
||||
- source-side действия:
|
||||
- `Принять`
|
||||
- `Отклонить`
|
||||
- `Ответ во внешний контур`
|
||||
- зеркалирование из целевой задачи в source-side карточку:
|
||||
- комментарии
|
||||
- вложения
|
||||
- activity
|
||||
- обновления статуса
|
||||
- proxy download для вложений без прямого membership в target project
|
||||
- unread-индикатор новых изменений по source-side карточке
|
||||
- блок `Маршрутизация` в фиксированном формате `3 x 3`
|
||||
|
||||
Текущее ограничение MVP:
|
||||
- выбор `Внешнего контура` сейчас доступен только среди проектов, в которых отправитель уже состоит
|
||||
- это осознанное упрощение первого рабочего вертикального среза
|
||||
- за счет этого source-side карточка может безопасно использовать обычные target issue API без отдельного proxy-слоя для комментариев и файлов
|
||||
## Freeze point на 2026-04-19
|
||||
|
||||
Текущий этап временно заморожен.
|
||||
|
||||
Заморозка делается после достижения рабочего вертикального среза:
|
||||
- запрос можно отправить из проекта-источника в другой проект того же `workspace`
|
||||
- в целевом проекте создается обычная задача и сразу попадает в обычный workflow
|
||||
- отправитель видит свой source-side список и карточку без обязательного доступа в чужой проект
|
||||
- в source-side карточке видны статус, маршрут, назначенный, срок, комментарии, вложения и activity из целевого контура
|
||||
- отправитель может поправить ошибку в `заголовке` и `описании`, пока запрос открыт
|
||||
- отправитель может принять результат во внутренний контур или вернуть запрос обратно во внешний контур с комментарием причины
|
||||
|
||||
На этом месте разработка следующего шага останавливается до фиксации продуктовых решений.
|
||||
|
||||
## Что нужно решить перед продолжением
|
||||
|
||||
- Что именно делает действие `Принять`:
|
||||
- только фиксирует решение источника
|
||||
- создает отдельную сущность во `Внутреннем контуре`
|
||||
- или переводит внешний запрос в отдельный внутренний режим без дублирования сущностей
|
||||
- Должен ли принятый запрос оставаться во `Внешних контурах` как историческая карточка, или он должен исчезать из рабочего списка и жить только во `Внутреннем контуре`
|
||||
- Нужен ли отдельный статус или отдельная вкладка для запросов, которые уже приняты во `Внутренний контур`
|
||||
- Должна ли коммуникация по комментариям быть полностью двусторонней, или source-side ответов достаточно только для возврата и уточнений
|
||||
- Нужно ли физически копировать файлы в source-side представление, или для PoC достаточно proxy-доступа к файлам целевой задачи
|
||||
- Нужно ли переводить обновление карточки с polling на realtime/push уже в следующем этапе, или polling пока приемлем
|
||||
- Нужны ли отдельные счетчики непрочитанных изменений по вкладкам `Открытые` и `Завершенные`
|
||||
- Должен ли отправитель после создания запроса иметь право менять только `заголовок` и `описание`, или еще и `назначенного`, `срок`, `приоритет`, `метки`
|
||||
- Нужно ли сохранять запрет на прямой переход в целевую задачу для пользователей без membership в target project как постоянное правило
|
||||
- Какой финальный lifecycle должен быть у запроса после возврата, принятия, завершения и отмены, чтобы source-side карточка не стала второй несогласованной системой учета
|
||||
|
||||
## Обязательные требования
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,23 @@
|
|||
3. потом синхронизация
|
||||
4. потом уведомления и полировка
|
||||
|
||||
## Freeze point на 2026-04-19
|
||||
|
||||
Текущий вертикальный срез временно заморожен.
|
||||
|
||||
На момент freeze point уже есть:
|
||||
- отправка запроса из source project в target project того же `workspace`
|
||||
- отсутствие требования прямого membership в target project для отправки
|
||||
- source-side список `Открытые / Завершенные`
|
||||
- source-side карточка на shell `Предложений`
|
||||
- source-side редактирование открытого запроса по `заголовку` и `описанию`
|
||||
- зеркалирование статуса, комментариев, вложений и activity из целевого контура
|
||||
- source-side действия `Принять`, `Отклонить`, `Ответ во внешний контур`
|
||||
- индикатор непрочитанных изменений
|
||||
- карточка `Маршрутизация` в целевом формате `3 x 3`
|
||||
|
||||
Дальше по roadmap пока не идем, пока не приняты продуктовые решения по внутреннему жизненному циклу принятого запроса.
|
||||
|
||||
## Этап 0. Термины и навигация
|
||||
|
||||
### Цель
|
||||
|
|
@ -242,35 +259,64 @@
|
|||
|
||||
## Открытые вопросы
|
||||
|
||||
### 1. Source-side сущность
|
||||
### 1. Что именно делает `Принять`
|
||||
|
||||
Нужно зафиксировать конечную бизнес-логику:
|
||||
- `Принять` только ставит source-side решение `accepted`
|
||||
- `Принять` создает отдельную сущность во `Внутреннем контуре`
|
||||
- `Принять` переводит карточку в отдельный внутренний статус без создания дубликата
|
||||
|
||||
Это главный блокирующий вопрос перед следующим этапом.
|
||||
|
||||
### 2. Где дальше живет принятый запрос
|
||||
|
||||
Нужно решить:
|
||||
- карточка остается во `Внешних контурах` как историческая запись
|
||||
- карточка уходит во `Внутренний контур`
|
||||
- карточка одновременно видна в обоих местах, но с разной ролью
|
||||
|
||||
### 3. Нужна ли отдельная сущность source-side
|
||||
|
||||
Нужно принять решение:
|
||||
- достаточно ли source-side проекции
|
||||
- или нужна отдельная таблица/модель для внешних контуров
|
||||
- достаточно ли текущей source-side проекции поверх bridge metadata
|
||||
- или уже пора вводить отдельную таблицу/модель для внешних контуров
|
||||
|
||||
### 2. Файлы
|
||||
### 4. Файлы
|
||||
|
||||
Нужно решить:
|
||||
- показываем ли мы source-side ссылку на target asset
|
||||
- или физически копируем файл в source representation
|
||||
- достаточно ли proxy-доступа к файлам целевой задачи
|
||||
- или файлы надо физически копировать в source-side representation
|
||||
- нужно ли зеркалировать inline-файлы из описания и комментариев
|
||||
|
||||
### 3. Комментарии
|
||||
### 5. Комментарии
|
||||
|
||||
Нужно решить:
|
||||
- комментарии зеркалируются односторонне из цели в источник
|
||||
- или источник тоже может отвечать прямо из source-side карточки
|
||||
- source-side reply остается облегченной обратной связью
|
||||
- или нужен полноценный двусторонний поток комментариев как единый discussion-thread
|
||||
|
||||
### 4. Уровень realtime
|
||||
### 6. Уровень realtime
|
||||
|
||||
Нужно решить:
|
||||
- хватит ли near-realtime через polling и existing refresh
|
||||
- или сразу нужен realtime через live-события
|
||||
- хватает ли polling для PoC
|
||||
- или следующий этап уже должен включать push/realtime события
|
||||
|
||||
### 5. Доступ к target issue
|
||||
### 7. Счетчики и вкладки
|
||||
|
||||
Нужно решить:
|
||||
- нужен ли отдельный unread-counter по вкладкам `Открытые / Завершенные`
|
||||
- нужен ли отдельный сегмент для запросов, принятых во `Внутренний контур`
|
||||
|
||||
### 8. Право редактирования после отправки
|
||||
|
||||
Нужно решить:
|
||||
- отправитель редактирует только `заголовок` и `описание`
|
||||
- или после отправки он может менять еще `срок`, `назначенного`, `приоритет`, `метки`
|
||||
|
||||
### 9. Доступ к target issue
|
||||
|
||||
Нужно решить:
|
||||
- должен ли инициатор иметь прямую ссылку на target issue
|
||||
- или доступ к target issue должен быть скрыт, а source-side карточка должна быть единственной точкой просмотра
|
||||
- или source-side карточка должна оставаться единственной точкой просмотра для пользователей без membership
|
||||
|
||||
Текущее решение:
|
||||
- при отсутствии membership в target project прямой переход в target issue скрывается
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ import { isEmpty } from "lodash-es";
|
|||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper";
|
||||
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
|
||||
import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list";
|
||||
import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions";
|
||||
import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items";
|
||||
import { SidebarUtilityRail } from "@/components/workspace/sidebar/sidebar-utility-rail";
|
||||
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
|
||||
// hooks
|
||||
import { useFavorite } from "@/hooks/store/use-favorite";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
|
@ -22,7 +23,6 @@ import { useUserPermissions } from "@/hooks/store/user";
|
|||
import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list";
|
||||
|
||||
export const AppSidebar = observer(function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { groupedFavorites } = useFavorite();
|
||||
|
|
@ -36,7 +36,11 @@ export const AppSidebar = observer(function AppSidebar() {
|
|||
const isFavoriteEmpty = isEmpty(groupedFavorites);
|
||||
|
||||
return (
|
||||
<SidebarWrapper title={t("projects")} quickActions={<SidebarQuickActions />}>
|
||||
<SidebarWrapper
|
||||
header={<WorkspaceMenuRoot variant="sidebar-panel" />}
|
||||
quickActions={<SidebarQuickActions />}
|
||||
footer={<SidebarUtilityRail />}
|
||||
>
|
||||
<SidebarMenuItems />
|
||||
{/* Favorites Menu */}
|
||||
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { TopNavPowerK } from "@/components/navigation";
|
|||
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
|
||||
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
|
||||
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
import { InboxIcon } from "@plane/propel/icons";
|
||||
|
|
@ -26,6 +27,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
|
|||
// store hooks
|
||||
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
|
||||
const { preferences } = useAppRailPreferences();
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const showLabel = preferences.displayMode === "icon_with_label";
|
||||
|
||||
|
|
@ -41,6 +43,8 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
|
|||
? unreadNotificationsCount.mention_unread_notifications_count
|
||||
: unreadNotificationsCount.total_unread_notifications_count;
|
||||
|
||||
if (!isMobile) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("z-[27] flex min-h-10 w-full items-center bg-canvas px-3.5 transition-all duration-300", {
|
||||
|
|
|
|||
|
|
@ -13,19 +13,28 @@ type TSidebarPropertyListItemProps = {
|
|||
children: ReactNode;
|
||||
appendElement?: ReactNode;
|
||||
childrenClassName?: string;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
};
|
||||
|
||||
export function SidebarPropertyListItem(props: TSidebarPropertyListItemProps) {
|
||||
const { icon: Icon, label, children, appendElement, childrenClassName } = props;
|
||||
const { icon: Icon, label, children, appendElement, childrenClassName, className, labelClassName } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex h-7.5 w-30 shrink-0 items-center gap-1.5 text-body-xs-regular text-tertiary">
|
||||
<div className={cn("grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-start gap-x-6", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-7.5 min-w-0 items-start gap-1.5 text-body-xs-regular text-tertiary",
|
||||
labelClassName
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span>{label}</span>
|
||||
<span className="min-w-0 break-words">{label}</span>
|
||||
{appendElement}
|
||||
</div>
|
||||
<div className={cn("flex grow flex-wrap items-center gap-1", childrenClassName)}>{children}</div>
|
||||
<div className={cn("flex min-w-0 flex-wrap items-center gap-1 justify-self-stretch", childrenClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ import { Combobox } from "@headlessui/react";
|
|||
// plane imports
|
||||
import { EUserPermissionsLevel, getRandomLabelColor } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { CheckIcon, SearchIcon, PlusIcon } from "@plane/propel/icons";
|
||||
import { CheckIcon, SearchIcon } from "@plane/propel/icons";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
// helpers
|
||||
|
|
@ -92,7 +91,7 @@ export const IssueLabelSelect = observer(function IssueLabelSelect(props: IIssue
|
|||
|
||||
const issueLabels = values ?? [];
|
||||
|
||||
const label = <span className="text-body-xs-medium text-placeholder">{t("label.select")}</span>;
|
||||
const label = <span className="text-body-xs-medium text-placeholder whitespace-nowrap">{t("label.select")}</span>;
|
||||
|
||||
const searchInputKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (query !== "" && e.key === "Escape") {
|
||||
|
|
@ -121,22 +120,20 @@ export const IssueLabelSelect = observer(function IssueLabelSelect(props: IIssue
|
|||
<>
|
||||
<Combobox
|
||||
as="div"
|
||||
className="size-full flex-shrink-0 text-left"
|
||||
className="inline-flex max-w-full flex-shrink-0 text-left"
|
||||
value={issueLabels}
|
||||
onChange={(value) => onSelect(value)}
|
||||
multiple
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
<Button
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
prependIcon={<PlusIcon />}
|
||||
className="flex h-7.5 items-center rounded-sm border-0 bg-transparent py-0.5 pl-0 pr-2 text-body-xs-medium text-placeholder whitespace-nowrap outline-none hover:bg-layer-transparent-hover active:bg-layer-transparent-active"
|
||||
onClick={() => !projectLabels && fetchLabels()}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
|
||||
<Combobox.Options className="fixed z-10">
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export const IssueParentSelect = observer(function IssueParentSelect(props: TIss
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-body-xs-medium text-placeholder">{t("issue.add.parent")}</span>
|
||||
<span className="text-body-xs-medium text-placeholder whitespace-nowrap">{t("issue.add.parent")}</span>
|
||||
)}
|
||||
{!disabled && (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
|
|||
className={cn(
|
||||
"relative h-full min-h-[120px] transition-all",
|
||||
{ "rounded-sm bg-layer-1": isDraggingOverColumn },
|
||||
{ "vertical-scrollbar scrollbar-md": !sub_group_by && !shouldOverlayBeVisible }
|
||||
{ "vertical-scrollbar scrollbar-md -mr-2 pr-2": !sub_group_by && !shouldOverlayBeVisible }
|
||||
)}
|
||||
ref={columnRef}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { createPortal } from "react-dom";
|
||||
// plane imports
|
||||
|
|
@ -27,6 +27,8 @@ import { PeekOverviewIssueDetails } from "./issue-detail";
|
|||
import { IssuePeekOverviewLoader } from "./loader";
|
||||
import { PeekOverviewProperties } from "./properties";
|
||||
|
||||
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
|
||||
|
||||
interface IIssueView {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
|
|
@ -60,9 +62,21 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||
const [isArchiveIssueModalOpen, setIsArchiveIssueModalOpen] = useState(false);
|
||||
const [isDuplicateIssueModalOpen, setIsDuplicateIssueModalOpen] = useState(false);
|
||||
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);
|
||||
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return 720;
|
||||
|
||||
const fallbackWidth = Math.max(640, Math.floor(window.innerWidth * 0.5));
|
||||
const storedWidth = window.localStorage.getItem(SIDE_PEEK_WIDTH_STORAGE_KEY);
|
||||
const parsedWidth = storedWidth ? parseInt(storedWidth, 10) : NaN;
|
||||
|
||||
return Number.isFinite(parsedWidth) ? parsedWidth : fallbackWidth;
|
||||
});
|
||||
const [isResizingPeek, setIsResizingPeek] = useState(false);
|
||||
// ref
|
||||
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const initialPeekWidthRef = useRef<number>(0);
|
||||
const initialMouseXRef = useRef<number>(0);
|
||||
// store hooks
|
||||
const {
|
||||
setPeekIssue,
|
||||
|
|
@ -85,6 +99,77 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||
const isAnyLocalModalOpen =
|
||||
isDeleteIssueModalOpen || isArchiveIssueModalOpen || isDuplicateIssueModalOpen || isEditIssueModalOpen;
|
||||
|
||||
const stopPeekResizing = useCallback(() => {
|
||||
setIsResizingPeek(false);
|
||||
}, []);
|
||||
|
||||
const handlePeekResize = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!isResizingPeek) return;
|
||||
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
const minWidth = 640;
|
||||
const deltaX = event.clientX - initialMouseXRef.current;
|
||||
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
|
||||
setSidePeekWidth(nextWidth);
|
||||
},
|
||||
[isResizingPeek]
|
||||
);
|
||||
|
||||
const startPeekResizing = useCallback(
|
||||
(event: ReactMouseEvent) => {
|
||||
if (peekMode !== "side-peek") return;
|
||||
event.preventDefault();
|
||||
setIsResizingPeek(true);
|
||||
initialPeekWidthRef.current = sidePeekWidth;
|
||||
initialMouseXRef.current = event.clientX;
|
||||
},
|
||||
[peekMode, sidePeekWidth]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowResize = () => {
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
return () => window.removeEventListener("resize", handleWindowResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth);
|
||||
|
||||
window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth));
|
||||
|
||||
if (clampedWidth !== sidePeekWidth) {
|
||||
setSidePeekWidth(clampedWidth);
|
||||
}
|
||||
}, [sidePeekWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizingPeek) return;
|
||||
|
||||
document.addEventListener("mousemove", handlePeekResize);
|
||||
document.addEventListener("mouseup", stopPeekResizing);
|
||||
document.addEventListener("mouseleave", stopPeekResizing);
|
||||
window.addEventListener("blur", stopPeekResizing);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handlePeekResize);
|
||||
document.removeEventListener("mouseup", stopPeekResizing);
|
||||
document.removeEventListener("mouseleave", stopPeekResizing);
|
||||
window.removeEventListener("blur", stopPeekResizing);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [handlePeekResize, isResizingPeek, stopPeekResizing]);
|
||||
|
||||
usePeekOverviewOutsideClickDetector(
|
||||
issuePeekOverviewRef,
|
||||
() => {
|
||||
|
|
@ -123,7 +208,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
|
||||
: `h-full w-full`,
|
||||
!embedIssue && {
|
||||
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] resize-x rounded-[28px] border md:w-[50%] md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
|
||||
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
|
||||
peekMode === "side-peek",
|
||||
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
|
||||
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
|
||||
|
|
@ -141,10 +226,19 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||
ref={issuePeekOverviewRef}
|
||||
className={peekOverviewIssueClassName}
|
||||
style={{
|
||||
width: !embedIssue && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
|
||||
boxShadow:
|
||||
"0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)",
|
||||
}}
|
||||
>
|
||||
{!embedIssue && peekMode === "side-peek" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-[26] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
|
||||
onMouseDown={startPeekResizing}
|
||||
role="separator"
|
||||
aria-label="Resize issue panel"
|
||||
/>
|
||||
)}
|
||||
{isError ? (
|
||||
<div className="relative h-screen w-full overflow-hidden">
|
||||
<IssuePeekOverviewError removeRoutePeekId={removeRoutePeekId} />
|
||||
|
|
|
|||
|
|
@ -21,7 +21,12 @@ import { useUser } from "@/hooks/store/user";
|
|||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useExpandableSearch } from "@/hooks/use-expandable-search";
|
||||
|
||||
export const TopNavPowerK = observer(() => {
|
||||
type TTopNavPowerKProps = {
|
||||
variant?: "top-navigation" | "sidebar";
|
||||
};
|
||||
|
||||
export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
|
||||
const { variant = "top-navigation" } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const params = useParams();
|
||||
|
|
@ -208,49 +213,111 @@ export const TopNavPowerK = observer(() => {
|
|||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div
|
||||
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
|
||||
"w-[554px]": isOpen,
|
||||
})}
|
||||
>
|
||||
{variant === "top-navigation" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
|
||||
{
|
||||
"bg-layer-1": isOpen,
|
||||
}
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
role="button"
|
||||
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
|
||||
"w-[554px]": isOpen,
|
||||
})}
|
||||
>
|
||||
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!isOpen) openPanel();
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search commands..."
|
||||
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
|
||||
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
|
||||
{
|
||||
"bg-layer-1": isOpen,
|
||||
}
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
role="button"
|
||||
>
|
||||
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!isOpen) openPanel();
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search commands..."
|
||||
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
|
||||
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn("relative z-30 h-8 transition-all duration-300 ease-in-out", {
|
||||
"w-[19.5rem]": isOpen,
|
||||
"w-8": !isOpen,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-0 top-0 z-10 flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
openPanel();
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}}
|
||||
aria-label="Поиск"
|
||||
>
|
||||
<SearchIcon className="size-3.5 shrink-0 text-placeholder" />
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center overflow-hidden rounded-full transition-all duration-300",
|
||||
{
|
||||
"nodedc-glass-surface pl-10 pr-3": isOpen,
|
||||
"border-transparent bg-transparent pl-0 pr-0 shadow-none": !isOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!isOpen) openPanel();
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search commands..."
|
||||
className={cn(
|
||||
"placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none transition-all",
|
||||
{
|
||||
"pointer-events-none w-0 opacity-0": !isOpen,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
{isOpen && searchTerm && (
|
||||
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
|
||||
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"shadow-lg absolute -top-[6px] left-1/2 z-20 flex -translate-x-1/2 flex-col overflow-hidden rounded-md border border-subtle bg-surface-1 px-0 pt-10 transition-all duration-300 ease-in-out",
|
||||
"absolute z-20 flex flex-col overflow-hidden px-0 transition-all duration-300 ease-in-out",
|
||||
{
|
||||
"max-h-[80vh] w-[574px] opacity-100": isOpen,
|
||||
"max-h-[80vh] w-[574px] opacity-100": isOpen && variant === "top-navigation",
|
||||
"max-h-[70vh] w-[20rem] opacity-100": isOpen && variant === "sidebar",
|
||||
"h-0 w-0 opacity-0": !isOpen,
|
||||
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10": variant === "top-navigation",
|
||||
"nodedc-glass-modal nodedc-glass-surface bottom-11 left-0 rounded-[1.5rem] pt-3": variant === "sidebar",
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ export function ResizableSidebar({
|
|||
<div
|
||||
id="main-sidebar"
|
||||
className={cn(
|
||||
"z-20 h-full border-r border-subtle bg-surface-1",
|
||||
"z-20 h-full border-r border-subtle bg-transparent",
|
||||
!isResizing && "transition-all duration-300 ease-in-out",
|
||||
isCollapsed ? "w-0 translate-x-[-100%] opacity-0" : "translate-x-0 opacity-100",
|
||||
isMobile && "absolute",
|
||||
|
|
@ -197,7 +197,7 @@ export function ResizableSidebar({
|
|||
>
|
||||
<aside
|
||||
className={cn(
|
||||
"group/sidebar relative flex h-full w-full flex-col overflow-hidden bg-surface-1 pt-3",
|
||||
"group/sidebar relative flex h-full w-full flex-col overflow-visible bg-transparent",
|
||||
isAnyExtendedSidebarExpanded && "rounded-none"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -19,13 +19,15 @@ import { AppSidebarToggleButton } from "./sidebar-toggle-button";
|
|||
import { IconButton } from "@plane/propel/icon-button";
|
||||
|
||||
type TSidebarWrapperProps = {
|
||||
title: string;
|
||||
title?: string;
|
||||
header?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
quickActions?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWrapperProps) {
|
||||
const { title, children, quickActions } = props;
|
||||
const { title, header, children, quickActions, footer } = props;
|
||||
// state
|
||||
const [isCustomizeNavDialogOpen, setIsCustomizeNavDialogOpen] = useState(false);
|
||||
// store hooks
|
||||
|
|
@ -48,14 +50,12 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
|
|||
return (
|
||||
<>
|
||||
<CustomizeNavigationDialog isOpen={isCustomizeNavDialogOpen} onClose={() => setIsCustomizeNavDialogOpen(false)} />
|
||||
<div ref={ref} className="flex h-full w-full animate-fade-in flex-col">
|
||||
<div className="flex flex-col gap-3 px-3">
|
||||
{/* Workspace switcher and settings */}
|
||||
|
||||
<div ref={ref} className="nodedc-glass-sidebar flex h-full w-full animate-fade-in flex-col">
|
||||
<div className="flex flex-col gap-3 px-3 pt-3">
|
||||
<div className="flex items-center justify-between gap-2 px-2">
|
||||
<span className="pt-1 text-16 font-medium text-primary">{title}</span>
|
||||
<div className="min-w-0 flex-1">{header ?? <span className="pt-1 text-16 font-medium text-primary">{title}</span>}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{title === "Projects" && (
|
||||
{title === "Projects" && !header && (
|
||||
<IconButton
|
||||
size="base"
|
||||
variant="ghost"
|
||||
|
|
@ -79,6 +79,7 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
|
|||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
{footer && <div className="px-3 pt-2 pb-3">{footer}</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -46,12 +46,12 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
|||
<Menu.Item
|
||||
as="div"
|
||||
className={cn("px-4 py-2", {
|
||||
"bg-layer-transparent-active": workspace.id === activeWorkspace?.id,
|
||||
"hover:bg-layer-transparent-hover": workspace.id !== activeWorkspace?.id,
|
||||
"bg-white/[0.03]": workspace.id === activeWorkspace?.id,
|
||||
"hover:bg-white/[0.05]": workspace.id !== activeWorkspace?.id,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1 rounded-sm p-1 text-13 text-primary">
|
||||
<div className="relative flex w-[80%] items-center justify-start gap-2.5">
|
||||
<div className="flex items-center justify-between gap-2 rounded-[1rem] p-1.5 text-13 text-primary">
|
||||
<div className="relative flex w-[82%] items-center justify-start gap-2.5">
|
||||
<span
|
||||
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center border-subtle p-2 text-14 font-medium uppercase ${
|
||||
!workspace?.logo_url && "rounded-md bg-[#026292] text-on-color"
|
||||
|
|
@ -90,7 +90,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
|||
</div>
|
||||
{workspace.id === activeWorkspace?.id && (
|
||||
<>
|
||||
<div className="mt-2 mb-1 flex gap-2">
|
||||
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
|
||||
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
|
||||
<Link
|
||||
href={`/${workspace.slug}/settings`}
|
||||
|
|
@ -98,7 +98,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
|||
e.stopPropagation();
|
||||
handleClose();
|
||||
}}
|
||||
className="flex gap-1.5 rounded-md border border-strong bg-layer-2 px-2.5 py-1.5 text-secondary transition-colors hover:border-strong hover:text-secondary hover:shadow-raised-100"
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
|
||||
>
|
||||
<Settings className="my-auto h-4 w-4 flex-shrink-0" />
|
||||
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span>
|
||||
|
|
@ -111,7 +111,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
|||
e.stopPropagation();
|
||||
handleClose();
|
||||
}}
|
||||
className="flex gap-1.5 rounded-md border border-strong bg-layer-2 px-2.5 py-1.5 text-secondary transition-colors hover:border-strong hover:text-secondary hover:shadow-raised-100"
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
|
||||
>
|
||||
<UserPlus className="my-auto h-4 w-4 flex-shrink-0" />
|
||||
<span className="my-auto text-13 font-medium whitespace-nowrap">
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_ELEMENTS } from
|
|||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { LinkIcon, ArchiveIcon, ChevronRightIcon } from "@plane/propel/icons";
|
||||
import { LinkIcon, ArchiveIcon, ChevronRightIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { CustomMenu, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
|
||||
|
|
@ -337,15 +337,15 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
|||
: t("aria_labels.projects_sidebar.open_project_menu")
|
||||
}
|
||||
>
|
||||
<div className="grid size-4 flex-shrink-0 place-items-center">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
<div className="grid size-8 flex-shrink-0 place-items-center rounded-full border border-white/8 bg-white/[0.04]">
|
||||
<WorkItemsIcon className="size-4 text-tertiary" />
|
||||
</div>
|
||||
<p className="truncate text-13 font-medium text-secondary">{project.name}</p>
|
||||
</Disclosure.Button>
|
||||
) : (
|
||||
<div className="flex w-full flex-grow items-center gap-1.5 text-left select-none">
|
||||
<div className="grid size-4 flex-shrink-0 place-items-center">
|
||||
<Logo logo={project.logo_props} size={16} />
|
||||
<div className="flex w-full flex-grow items-center gap-3 text-left select-none">
|
||||
<div className="grid size-8 flex-shrink-0 place-items-center rounded-full border border-white/8 bg-white/[0.04]">
|
||||
<WorkItemsIcon className="size-4 text-tertiary" />
|
||||
</div>
|
||||
<p className="truncate text-13 font-medium text-secondary">{project.name}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -70,8 +70,10 @@ export const SidebarItemBase = observer(function SidebarItemBase({
|
|||
return (
|
||||
<Link href={itemHref} onClick={handleLinkClick}>
|
||||
<SidebarNavItem isActive={item.highlight(pathname, itemHref)}>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
{icon}
|
||||
<div className="flex items-center gap-3 py-[1px]">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-tertiary transition-all group-hover:bg-white/[0.07]">
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-13 leading-5 font-medium">{t(item.labelTranslationKey)}</p>
|
||||
</div>
|
||||
{additionalRender?.(item.key, slug)}
|
||||
|
|
|
|||
|
|
@ -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 { useUser } from "@/hooks/store/user";
|
||||
|
||||
export const UserMenuRoot = observer(function UserMenuRoot() {
|
||||
type TUserMenuRootProps = {
|
||||
variant?: "default" | "sidebar-utility";
|
||||
};
|
||||
|
||||
export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) {
|
||||
const { variant = "default" } = props;
|
||||
// states
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
// router
|
||||
|
|
@ -57,20 +62,34 @@ export const UserMenuRoot = observer(function UserMenuRoot() {
|
|||
<CustomMenu
|
||||
className="flex items-center"
|
||||
customButton={
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: (
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={20}
|
||||
shape="circle"
|
||||
/>
|
||||
),
|
||||
isActive: isUserMenuOpen,
|
||||
}}
|
||||
/>
|
||||
variant === "sidebar-utility" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
|
||||
>
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={18}
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: (
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={20}
|
||||
shape="circle"
|
||||
/>
|
||||
),
|
||||
isActive: isUserMenuOpen,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
|
||||
onMenuClose={() => setIsUserMenuOpen(false)}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { WorkspaceLogo } from "../logo";
|
|||
import SidebarDropdownItem from "./dropdown-item";
|
||||
|
||||
type WorkspaceMenuRootProps = {
|
||||
variant: "sidebar" | "top-navigation";
|
||||
variant: "sidebar" | "top-navigation" | "sidebar-panel";
|
||||
};
|
||||
|
||||
export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: WorkspaceMenuRootProps) {
|
||||
|
|
@ -77,9 +77,10 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
return (
|
||||
<Menu
|
||||
as="div"
|
||||
className={cn("relative flex h-full w-fit max-w-48 truncate whitespace-nowrap", {
|
||||
className={cn("relative z-20 flex h-full w-fit max-w-48 whitespace-nowrap overflow-visible", {
|
||||
"w-full justify-center text-center": variant === "sidebar",
|
||||
"flex-grow justify-stretch truncate text-left": variant === "top-navigation",
|
||||
"flex-grow justify-stretch text-left": variant === "top-navigation",
|
||||
"w-full max-w-none justify-stretch text-left": variant === "sidebar-panel",
|
||||
})}
|
||||
>
|
||||
{({ open, close }: { open: boolean; close: () => void }) => {
|
||||
|
|
@ -135,6 +136,35 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
/>
|
||||
</Menu.Button>
|
||||
)}
|
||||
{variant === "sidebar-panel" && (
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
"group/menu-button flex w-full items-center justify-between gap-2 px-0 py-1 text-left text-13 font-medium text-secondary transition-all focus:outline-none",
|
||||
{
|
||||
"text-primary": open,
|
||||
}
|
||||
)}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 truncate">
|
||||
<WorkspaceLogo
|
||||
logo={activeWorkspace?.logo_url}
|
||||
name={activeWorkspace?.name}
|
||||
classNames="size-8 rounded-[0.9rem]"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate text-14 font-medium text-primary">
|
||||
{activeWorkspace?.name ?? t("loading")}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={cn("size-4 flex-shrink-0 text-placeholder duration-300", {
|
||||
"rotate-180": open,
|
||||
})}
|
||||
/>
|
||||
</Menu.Button>
|
||||
)}
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
|
|
@ -147,15 +177,25 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
<Menu.Items as={Fragment}>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed z-21 mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200 outline-none",
|
||||
"z-21 mt-1 flex min-w-[30rem] origin-top-left flex-col divide-y outline-none",
|
||||
{
|
||||
"fixed divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200": variant !== "sidebar-panel",
|
||||
"top-11 left-14": variant === "sidebar",
|
||||
"top-10 left-4": variant === "top-navigation",
|
||||
"nodedc-glass-modal nodedc-glass-surface absolute top-full left-0 z-[140] mt-2 rounded-[1.5rem] divide-white/10": variant === "sidebar-panel",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="vertical-scrollbar flex scrollbar-sm max-h-96 flex-col items-start justify-start overflow-x-hidden overflow-y-scroll">
|
||||
<span className="sticky top-0 z-21 h-full w-full flex-shrink-0 truncate rounded-md bg-surface-1 px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder">
|
||||
<span
|
||||
className={cn(
|
||||
"sticky top-0 z-21 h-full w-full flex-shrink-0 truncate px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder",
|
||||
{
|
||||
"rounded-md bg-surface-1": variant !== "sidebar-panel",
|
||||
"bg-transparent": variant === "sidebar-panel",
|
||||
}
|
||||
)}
|
||||
>
|
||||
{currentUser?.email}
|
||||
</span>
|
||||
{workspacesList ? (
|
||||
|
|
@ -191,7 +231,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
<Link href="/create-workspace" className="w-full">
|
||||
<Menu.Item
|
||||
as="div"
|
||||
className="flex items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
|
||||
className="flex items-center gap-2 rounded-xl px-2 py-1.5 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
|
||||
>
|
||||
<CirclePlus className="size-4 flex-shrink-0" />
|
||||
{t("create_workspace")}
|
||||
|
|
@ -202,7 +242,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
<Link href="/invitations" className="w-full" onClick={handleItemClick}>
|
||||
<Menu.Item
|
||||
as="div"
|
||||
className="flex items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
|
||||
className="flex items-center gap-2 rounded-xl px-2 py-1.5 text-13 font-medium text-secondary hover:bg-layer-transparent-hover"
|
||||
>
|
||||
<Mails className="h-4 w-4 flex-shrink-0" />
|
||||
{t("workspace_invites")}
|
||||
|
|
@ -213,7 +253,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
<Menu.Item
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1 text-13 font-medium text-danger-primary hover:bg-layer-transparent-hover"
|
||||
className="flex w-full items-center gap-2 rounded-xl px-2 py-1.5 text-13 font-medium text-danger-primary hover:bg-layer-transparent-hover"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="size-4 flex-shrink-0" />
|
||||
|
|
|
|||
|
|
@ -45,6 +45,22 @@ export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
|
|||
// Outside click detection
|
||||
useOutsideClickDetector(containerRef, handleOutsideClick);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
if (containerRef.current?.contains(target)) return;
|
||||
handleClose();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
// Track keyboard shortcuts that trigger focus (Cmd+F / Ctrl+F)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -198,3 +198,58 @@
|
|||
0 0 4px --alpha(var(--background-color-accent-primary) / 40%) !important;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.nodedc-glass-sidebar {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.018) 100%),
|
||||
rgba(7, 7, 9, 0.84);
|
||||
backdrop-filter: blur(40px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||
0 18px 48px rgba(0, 0, 0, 0.26);
|
||||
}
|
||||
|
||||
.nodedc-glass-modal {
|
||||
box-shadow:
|
||||
0 20px 56px rgba(0, 0, 0, 0.34),
|
||||
0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.nodedc-glass-surface {
|
||||
background: rgba(11, 11, 14, 0.82);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 20px 56px rgba(0, 0, 0, 0.34),
|
||||
0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.nodedc-glass-modal [data-slot="button"],
|
||||
.nodedc-glass-modal [data-slot="icon-button"] {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
border-radius: 1.25rem !important;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.nodedc-glass-modal [data-slot="button"] {
|
||||
min-height: 2.5rem;
|
||||
padding-inline: 0.95rem;
|
||||
}
|
||||
|
||||
.nodedc-glass-modal [data-slot="icon-button"] {
|
||||
min-height: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.nodedc-glass-modal button:focus-visible,
|
||||
.nodedc-glass-modal [role="button"]:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -973,7 +973,7 @@ export default {
|
|||
assignee: "Add assignees",
|
||||
start_date: "Add start date",
|
||||
due_date: "Add due date",
|
||||
parent: "Add parent work item",
|
||||
parent: "Add parent item",
|
||||
sub_issue: "Add sub-work item",
|
||||
relation: "Add relation",
|
||||
link: "Add link",
|
||||
|
|
|
|||
|
|
@ -1130,7 +1130,7 @@ export default {
|
|||
assignee: "Добавить ответственных",
|
||||
start_date: "Добавить дату начала",
|
||||
due_date: "Добавить срок выполнения",
|
||||
parent: "Добавить родительский рабочий элемент",
|
||||
parent: "Добавить родительский элемент",
|
||||
sub_issue: "Добавить подэлемент",
|
||||
relation: "Добавить связь",
|
||||
link: "Добавить ссылку",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const Button = React.forwardRef(function Button(props: ButtonProps, ref: React.F
|
|||
|
||||
return (
|
||||
<button
|
||||
data-slot="button"
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,9 @@ export interface DialogTitleProps extends React.ComponentProps<typeof BaseDialog
|
|||
}
|
||||
|
||||
// Constants
|
||||
const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-backdrop");
|
||||
const BASE_CLASSNAME = "relative text-left bg-surface-1 rounded-lg shadow-md w-full z-100 border border-subtle";
|
||||
const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-backdrop/70 backdrop-blur-sm");
|
||||
const BASE_CLASSNAME =
|
||||
"nodedc-glass-modal relative w-full rounded-[28px] border border-subtle/70 bg-surface-1/78 text-left shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl z-100";
|
||||
|
||||
// Utility functions
|
||||
const getPositionClassNames = (position: DialogPosition) =>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ const IconButton = React.forwardRef(function IconButton(
|
|||
|
||||
return (
|
||||
<button
|
||||
data-slot="icon-button"
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(iconButtonVariants({ variant, size }), className)}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export function ModalPortal({
|
|||
const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position];
|
||||
|
||||
return cn(
|
||||
"shadow-lg absolute top-0 h-full bg-white transition-transform duration-300 ease-out",
|
||||
"nodedc-glass-modal absolute top-0 h-full rounded-[28px] border border-subtle/70 bg-surface-1/78 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl transition-transform duration-300 ease-out",
|
||||
widthClass,
|
||||
positionClass,
|
||||
contentClassName
|
||||
|
|
@ -101,7 +101,7 @@ export function ModalPortal({
|
|||
>
|
||||
{showOverlay && (
|
||||
<div
|
||||
className={cn("absolute inset-0 bg-black/50 transition-colors duration-300", overlayClassName)}
|
||||
className={cn("absolute inset-0 bg-black/50 backdrop-blur-sm transition-colors duration-300", overlayClassName)}
|
||||
onClick={handleOverlayClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function ModalCore(props: Props) {
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-backdrop transition-opacity" />
|
||||
<div className="fixed inset-0 bg-backdrop/70 backdrop-blur-sm transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
|
|
@ -57,7 +57,7 @@ export function ModalCore(props: Props) {
|
|||
>
|
||||
<Dialog.Panel
|
||||
className={cn(
|
||||
"relative w-full transform rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all",
|
||||
"nodedc-glass-modal relative w-full transform rounded-[28px] border border-subtle/70 bg-surface-1/78 text-left shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl transition-all",
|
||||
width,
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue