feat(settings): discovery hero rollout + ephemeral playground token (#21072)
## Summary
Two intertwined streams of work:
### UI — discovery hero pattern, settings shell, AI/API redesign
- **Generalize `SettingsDiscoveryHeroCard`** and use it on Layout, Data
Model, Apps, AI, API/Webhooks, Members. Drops 4 per-page wrapper files
(`SettingsObjectCoverImage`, `SettingsLayoutCoverImage`,
`SettingsLayoutCustomizeVideoModal`,
`SettingsDataModelVisualizeVideoModal`). Each page now supplies cover
src, modal id, and tab list.
- **Modal**: swap `<video>` placeholder for the Vimeo iframe pattern
from `twenty-docs`, per-tab `vimeoId`. Drop the parallel border-bottom
on the header (TabList draws its own baseline) and the grey background
behind the video. Note: Vimeo's embed allowlist applies — the iframes
load with the correct URL on `localhost` but the player itself requires
the video owner to allow the dev/staging domains in Vimeo settings.
- **AI page** rebuilt into a Cockpit pattern (Overview / Models / Skills
/ Tools / Usage). New `SettingsAiOverviewTab` with default Smart/Fast
pickers, at-a-glance stats, and an MCP signpost that deep-links to
`/settings/api-webhooks#mcp`. System Prompt link moved under Models.
Advanced tab removed.
- **API & Webhooks** now has 4 tabs (Playground / MCP / API Keys /
Webhooks). Hero card above tabs. Playground tab inverted to "Core API" /
"Metadata API" sections, each containing REST + GraphQL cards — schema
is the meaningful axis, protocol is secondary. Hash deep-link sync
delegated to the shared `TabListFromUrlOptionalEffect`.
- **Settings shell**: unified drawer outer padding (kill `isSettings`
branch), extract `CollapsibleNavigationDrawerSection`, add `iconColor`
on settings nav items, fix Exit Settings button alignment, 880px content
cap.
### Backend — strategy C: ephemeral playground token
The legacy paste-your-API-key flow is replaced by an on-demand
short-lived token scoped to the calling user's permissions. No shared
"Playground" API key to manage or revoke.
- New `JwtTokenTypeEnum.PLAYGROUND`. `PlaygroundTokenJwtPayload =
Omit<AccessTokenJwtPayload, 'type' | impersonation fields>` so any
future ACCESS claim flows through automatically.
- `AccessTokenService.generatePlaygroundToken` signs an access-shaped
JWT with `type: PLAYGROUND` and a configurable short TTL. A shared
private `resolveTokenSubject` helper parallelizes the user / workspace /
userWorkspace lookups for both generators.
- `JwtAuthStrategy.validateAccessToken` widened to accept
`AccessTokenJwtPayload | PlaygroundTokenJwtPayload`; impersonation gated
on `payload.type === ACCESS` so the union narrows without `as unknown
as` casts. The two branches in `validate()` collapse into one.
- New `PLAYGROUND_TOKEN_EXPIRES_IN` config var (default `2h`).
- New `generatePlaygroundToken` mutation (`WorkspaceAuthGuard`, no args,
returns `AuthToken`).
- Frontend `useOpenPlayground` hook centralizes mint → atom write →
navigate, with Apollo `onError` snackbar and a "use cached PLAYGROUND
token if still fresh" short-circuit (decodes via `jwt-decode`, checks
both `type` AND `exp`). Old API_KEY tokens left in localStorage from the
prior paste-form flow are rejected on `type` alone and force a re-mint —
this is what was causing the "This API Key is revoked" symptom on stale
browsers.
### Drive-by cleanups
- `PlaygroundToken` DTO removed (identical shape to `AuthToken` already
in use).
- 5 `customize-sidebar.webm` imports and the dead placeholder pipeline
removed.
## Test plan
### Discovery hero
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the discovery hero card with its
illustration + play button + tabbed modal
- [ ] Modal tabs show the correct Vimeo embed URL per tab; aspect ratio
stays at 1440/900; no parallel border-bottom jog at the tab baseline
- [ ] AI Overview tab shows Smart/Fast model pickers + stats grid + MCP
signpost card; the MCP card lands on `/settings/api-webhooks#mcp` with
the MCP tab active
### API playground (ephemeral token)
- [ ] With an empty `playgroundApiKeyState` in localStorage, clicking
REST or GraphQL playground card opens the playground and the cached
token has `type: "PLAYGROUND"` with ~2h exp
- [ ] Clicking the card again within the freshness window does **not**
re-mint (`iat` / fingerprint stable across visits)
- [ ] Planting a fake API_KEY-shaped JWT in localStorage and clicking
the card forces a fresh mint (old token rejected on `type`)
- [ ] `GET /rest/companies?limit=1` with the cached token returns 200 +
real data
- [ ] `POST /graphql { __typename }` returns 200
### Settings shell
- [ ] Settings nav matches main app drawer padding; sections collapse;
Exit Settings button aligns with the workspace links above
- [ ] Active nav items have a right-gap (cleaner active state)
- [ ] Content area capped at 880px
### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const FIND_WORKSPACE_AI_STATS = gql`
|
||||
query FindWorkspaceAiStats {
|
||||
findWorkspaceAiStats {
|
||||
conversationsCount
|
||||
skillsCount
|
||||
toolsCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -220,6 +220,12 @@ const SettingsApplicationCommandMenuItemDetail = lazy(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const SettingsLayout = lazy(() =>
|
||||
import('~/pages/settings/layout/SettingsLayout').then((module) => ({
|
||||
default: module.SettingsLayout,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsLayoutViewDetail = lazy(() =>
|
||||
import('~/pages/settings/layout/SettingsLayoutViewDetail').then((module) => ({
|
||||
default: module.SettingsLayoutViewDetail,
|
||||
@@ -666,6 +672,40 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
|
||||
path={SettingsPath.ApiWebhooks}
|
||||
element={<SettingsApiWebhooks />}
|
||||
/>
|
||||
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
|
||||
<Route path={SettingsPath.Usage} element={<SettingsUsage />} />
|
||||
<Route
|
||||
path={SettingsPath.UsageUserDetail}
|
||||
element={<SettingsUsageUserDetail />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.Subdomain}
|
||||
element={<SettingsSubdomainPage />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.CustomDomain}
|
||||
element={<SettingsCustomDomainPage />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.NewEmailingDomain}
|
||||
element={<SettingsNewEmailingDomain />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.EmailingDomainDetail}
|
||||
element={<SettingsEmailingDomainDetail />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.PublicDomain}
|
||||
element={<SettingPublicDomain />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
<SettingsProtectedRouteWrapper
|
||||
settingsPermission={PermissionFlagType.AI}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path={SettingsPath.AI} element={<SettingsAI />} />
|
||||
<Route path={SettingsPath.AiPrompts} element={<SettingsAiPrompts />} />
|
||||
<Route
|
||||
@@ -700,32 +740,15 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
|
||||
path={SettingsPath.LogicFunctionDetail}
|
||||
element={<SettingsLogicFunctionDetail />}
|
||||
/>
|
||||
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
|
||||
<Route path={SettingsPath.Usage} element={<SettingsUsage />} />
|
||||
<Route
|
||||
path={SettingsPath.UsageUserDetail}
|
||||
element={<SettingsUsageUserDetail />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.Subdomain}
|
||||
element={<SettingsSubdomainPage />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.CustomDomain}
|
||||
element={<SettingsCustomDomainPage />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.NewEmailingDomain}
|
||||
element={<SettingsNewEmailingDomain />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.EmailingDomainDetail}
|
||||
element={<SettingsEmailingDomainDetail />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.PublicDomain}
|
||||
element={<SettingPublicDomain />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
<SettingsProtectedRouteWrapper
|
||||
settingsPermission={PermissionFlagType.LAYOUTS}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path={SettingsPath.Layout} element={<SettingsLayout />} />
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GENERATE_PLAYGROUND_TOKEN = gql`
|
||||
mutation GeneratePlaygroundToken {
|
||||
generatePlaygroundToken {
|
||||
token
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -3,7 +3,6 @@ import { safeRemoveLocalStorageItems } from '@/auth/utils/safeRemoveLocalStorage
|
||||
const SESSION_KEYS_TO_CLEAR = [
|
||||
'lastVisitedObjectMetadataItemIdState',
|
||||
'lastVisitedViewPerObjectMetadataItemState',
|
||||
'playgroundApiKeyState',
|
||||
'ai/agentChatDraftsByThreadIdState',
|
||||
'locale',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
|
||||
import { type FlatFrontComponent } from '@/metadata-store/types/FlatFrontComponent';
|
||||
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
|
||||
|
||||
export const frontComponentsSelector = createAtomSelector<FlatFrontComponent[]>(
|
||||
{
|
||||
key: 'frontComponentsSelector',
|
||||
get: ({ get }) => {
|
||||
const storeItem = get(metadataStoreState, 'frontComponents');
|
||||
|
||||
return storeItem.current as FlatFrontComponent[];
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -24,13 +24,16 @@ export const useEnterLayoutCustomizationMode = () => {
|
||||
const { navigateSidePanel } = useNavigateSidePanel();
|
||||
const { enqueueWarningSnackBar } = useSnackBar();
|
||||
|
||||
const enterLayoutCustomizationMode = useCallback(() => {
|
||||
// Returns whether customization mode is active afterward, so callers that
|
||||
// navigate on entry can skip navigation when entry was blocked (e.g. a
|
||||
// dashboard is mid-edit).
|
||||
const enterLayoutCustomizationMode = useCallback((): boolean => {
|
||||
const isLayoutCustomizationModeAlreadyEnabled = store.get(
|
||||
isLayoutCustomizationModeEnabledState.atom,
|
||||
);
|
||||
|
||||
if (isLayoutCustomizationModeAlreadyEnabled) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const dashboardPageLayoutIdInEditMode = store.get(
|
||||
@@ -49,7 +52,7 @@ export const useEnterLayoutCustomizationMode = () => {
|
||||
message: t`Save or cancel dashboard changes before editing the layout.`,
|
||||
});
|
||||
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +85,8 @@ export const useEnterLayoutCustomizationMode = () => {
|
||||
resetNavigationStack: true,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [enqueueWarningSnackBar, navigateSidePanel, store]);
|
||||
|
||||
return { enterLayoutCustomizationMode };
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { NavigationDrawerAiChatContent } from '@/ai/components/NavigationDrawerAiChatContent';
|
||||
import { MainNavigationDrawerTabsRow } from '@/navigation/components/MainNavigationDrawerTabsRow';
|
||||
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
|
||||
import { SettingsNavigationDrawerItems } from '@/settings/components/SettingsNavigationDrawerItems';
|
||||
import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer';
|
||||
import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
|
||||
import { NavigationDrawerScrollableContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerScrollableContent';
|
||||
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
|
||||
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
|
||||
import { NAVIGATION_DRAWER_TABS } from '@/ui/navigation/states/navigationDrawerTabs';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
import { AdvancedSettingsToggle } from 'twenty-ui/navigation';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { PermissionFlagType } from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledAdvancedToggleWrapper = styled.div<{ isMobile: boolean }>`
|
||||
padding-left: ${({ isMobile }) =>
|
||||
isMobile ? '0' : themeCssVariables.spacing[5]};
|
||||
padding-right: ${({ isMobile }) =>
|
||||
isMobile ? '0' : themeCssVariables.spacing[8]};
|
||||
`;
|
||||
|
||||
export const SettingsNavigationDrawer = ({
|
||||
className,
|
||||
@@ -13,23 +30,46 @@ export const SettingsNavigationDrawer = ({
|
||||
className?: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const isMobile = useIsMobile();
|
||||
const [isAdvancedModeEnabled, setIsAdvancedModeEnabled] = useAtomState(
|
||||
isAdvancedModeEnabledState,
|
||||
);
|
||||
const navigationDrawerActiveTab = useAtomStateValue(
|
||||
navigationDrawerActiveTabState,
|
||||
);
|
||||
const hasAiPermission = useHasPermissionFlag(PermissionFlagType.AI);
|
||||
|
||||
const showAiChatContent =
|
||||
hasAiPermission &&
|
||||
navigationDrawerActiveTab === NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY;
|
||||
|
||||
return (
|
||||
<NavigationDrawer className={className} title={t`Exit Settings`}>
|
||||
{hasAiPermission && (
|
||||
<NavigationDrawerFixedContent>
|
||||
<MainNavigationDrawerTabsRow />
|
||||
</NavigationDrawerFixedContent>
|
||||
)}
|
||||
|
||||
<NavigationDrawerScrollableContent>
|
||||
<SettingsNavigationDrawerItems />
|
||||
{showAiChatContent ? (
|
||||
<NavigationDrawerAiChatContent />
|
||||
) : (
|
||||
<SettingsNavigationDrawerItems />
|
||||
)}
|
||||
</NavigationDrawerScrollableContent>
|
||||
|
||||
<NavigationDrawerFixedContent>
|
||||
<AdvancedSettingsToggle
|
||||
isAdvancedModeEnabled={isAdvancedModeEnabled}
|
||||
setIsAdvancedModeEnabled={setIsAdvancedModeEnabled}
|
||||
label={t`Advanced:`}
|
||||
/>
|
||||
</NavigationDrawerFixedContent>
|
||||
{!showAiChatContent && (
|
||||
<NavigationDrawerFixedContent>
|
||||
<StyledAdvancedToggleWrapper isMobile={isMobile}>
|
||||
<AdvancedSettingsToggle
|
||||
isAdvancedModeEnabled={isAdvancedModeEnabled}
|
||||
setIsAdvancedModeEnabled={setIsAdvancedModeEnabled}
|
||||
label={t`Advanced:`}
|
||||
/>
|
||||
</StyledAdvancedToggleWrapper>
|
||||
</NavigationDrawerFixedContent>
|
||||
)}
|
||||
</NavigationDrawer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,11 +19,16 @@ const StyledCardsContainer = styled.div`
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
margin-top: ${themeCssVariables.spacing[6]};
|
||||
|
||||
@media (max-width: ${MOBILE_VIEWPORT}pxF) {
|
||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCardLinkSlot = styled.div`
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
export const SettingsAccountsSettingsSection = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { t } = useLingui();
|
||||
@@ -34,30 +39,34 @@ export const SettingsAccountsSettingsSection = () => {
|
||||
description={t`Configure your emails and calendar settings.`}
|
||||
/>
|
||||
<StyledCardsContainer>
|
||||
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsEmails)}>
|
||||
<SettingsCard
|
||||
Icon={
|
||||
<IconMailCog
|
||||
size={theme.icon.size.lg}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
}
|
||||
title={t`Emails`}
|
||||
description={t`Set email visibility, manage your blocklist and more.`}
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsCalendars)}>
|
||||
<SettingsCard
|
||||
Icon={
|
||||
<IconCalendarEvent
|
||||
size={theme.icon.size.lg}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
}
|
||||
title={t`Calendar`}
|
||||
description={t`Configure and customize your calendar preferences.`}
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
<StyledCardLinkSlot>
|
||||
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsEmails)}>
|
||||
<SettingsCard
|
||||
Icon={
|
||||
<IconMailCog
|
||||
size={theme.icon.size.lg}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
}
|
||||
title={t`Emails`}
|
||||
description={t`Set email visibility, manage your blocklist and more.`}
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
</StyledCardLinkSlot>
|
||||
<StyledCardLinkSlot>
|
||||
<UndecoratedLink to={getSettingsPath(SettingsPath.AccountsCalendars)}>
|
||||
<SettingsCard
|
||||
Icon={
|
||||
<IconCalendarEvent
|
||||
size={theme.icon.size.lg}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
}
|
||||
title={t`Calendar`}
|
||||
description={t`Configure and customize your calendar preferences.`}
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
</StyledCardLinkSlot>
|
||||
</StyledCardsContainer>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
import { type IconComponent, IconX } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
export type SettingsCustomizeVideoModalTab = {
|
||||
id: string;
|
||||
title: string;
|
||||
Icon: IconComponent;
|
||||
vimeoId: string;
|
||||
};
|
||||
|
||||
type SettingsCustomizeVideoModalProps = {
|
||||
modalInstanceId: string;
|
||||
tabsInstanceId: string;
|
||||
tabs: SettingsCustomizeVideoModalTab[];
|
||||
};
|
||||
|
||||
const StyledHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
height: 48px;
|
||||
justify-content: space-between;
|
||||
padding-right: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledTabsContainer = styled.div`
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding-left: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledVideoContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: ${themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
||||
const StyledVideoIframe = styled.iframe`
|
||||
aspect-ratio: 1440 / 900;
|
||||
border: 0;
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
box-shadow: ${themeCssVariables.boxShadow.strong};
|
||||
display: block;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
width: 960px;
|
||||
`;
|
||||
|
||||
export const SettingsCustomizeVideoModal = ({
|
||||
modalInstanceId,
|
||||
tabsInstanceId,
|
||||
tabs,
|
||||
}: SettingsCustomizeVideoModalProps) => {
|
||||
const { closeModal } = useModal();
|
||||
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id ?? '');
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
|
||||
|
||||
const handleClose = () => {
|
||||
closeModal(modalInstanceId);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalStatefulWrapper
|
||||
modalInstanceId={modalInstanceId}
|
||||
size="large"
|
||||
padding="none"
|
||||
isClosable
|
||||
onClose={handleClose}
|
||||
renderInDocumentBody
|
||||
>
|
||||
<StyledHeader>
|
||||
<StyledTabsContainer>
|
||||
<TabList
|
||||
tabs={tabs}
|
||||
behaveAsLinks={false}
|
||||
componentInstanceId={tabsInstanceId}
|
||||
onChangeTab={(tabId) => setActiveTabId(tabId)}
|
||||
/>
|
||||
</StyledTabsContainer>
|
||||
<IconButton Icon={IconX} onClick={handleClose} size="small" />
|
||||
</StyledHeader>
|
||||
<StyledVideoContainer>
|
||||
<StyledVideoIframe
|
||||
key={activeTab.id}
|
||||
src={`https://player.vimeo.com/video/${activeTab.vimeoId}?autoplay=1&loop=1&autopause=0&background=1&muted=1`}
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
title={activeTab.title}
|
||||
/>
|
||||
</StyledVideoContainer>
|
||||
</ModalStatefulWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
SettingsCustomizeVideoModal,
|
||||
type SettingsCustomizeVideoModalTab,
|
||||
} from '@/settings/components/SettingsCustomizeVideoModal';
|
||||
import { HeroPlayButton } from '@/ui/layout/hero/components/HeroPlayButton';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext } from 'react';
|
||||
import { Card } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const COVER_HEIGHT = 150;
|
||||
|
||||
const StyledCoverContainer = styled.div`
|
||||
background: ${themeCssVariables.background.secondary};
|
||||
box-sizing: border-box;
|
||||
height: ${COVER_HEIGHT}px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img`
|
||||
display: block;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledOverlay = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
type SettingsDiscoveryHeroCardProps = {
|
||||
lightSrc: string;
|
||||
darkSrc: string;
|
||||
instanceIdPrefix: string;
|
||||
tabs: SettingsCustomizeVideoModalTab[];
|
||||
playButtonAriaLabel?: string;
|
||||
};
|
||||
|
||||
export const SettingsDiscoveryHeroCard = ({
|
||||
lightSrc,
|
||||
darkSrc,
|
||||
instanceIdPrefix,
|
||||
tabs,
|
||||
playButtonAriaLabel,
|
||||
}: SettingsDiscoveryHeroCardProps) => {
|
||||
const { t } = useLingui();
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const modalInstanceId = `${instanceIdPrefix}-modal`;
|
||||
const tabsInstanceId = `${instanceIdPrefix}-tabs`;
|
||||
|
||||
const src = colorScheme === 'light' ? lightSrc : darkSrc;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card rounded>
|
||||
<StyledCoverContainer>
|
||||
<StyledImage src={src} alt="" aria-hidden />
|
||||
<StyledOverlay>
|
||||
<HeroPlayButton
|
||||
onClick={() => openModal(modalInstanceId)}
|
||||
ariaLabel={playButtonAriaLabel ?? t`Watch demo`}
|
||||
/>
|
||||
</StyledOverlay>
|
||||
</StyledCoverContainer>
|
||||
</Card>
|
||||
<SettingsCustomizeVideoModal
|
||||
modalInstanceId={modalInstanceId}
|
||||
tabsInstanceId={tabsInstanceId}
|
||||
tabs={tabs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -39,6 +39,7 @@ export const SettingsNavigationDrawerItem = ({
|
||||
label={item.label}
|
||||
to={href}
|
||||
Icon={item.Icon}
|
||||
withIconBackground
|
||||
active={isActive}
|
||||
modifier={item.modifier}
|
||||
onClick={item.onClick}
|
||||
@@ -54,6 +55,7 @@ export const SettingsNavigationDrawerItem = ({
|
||||
label={item.label}
|
||||
to={href || undefined}
|
||||
Icon={item.Icon}
|
||||
withIconBackground
|
||||
active={isActive}
|
||||
modifier={item.modifier}
|
||||
onClick={item.onClick}
|
||||
|
||||
@@ -5,13 +5,81 @@ import {
|
||||
type SettingsNavigationSection,
|
||||
useSettingsNavigationItems,
|
||||
} from '@/settings/hooks/useSettingsNavigationItems';
|
||||
import { CollapsibleNavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/CollapsibleNavigationDrawerSection';
|
||||
import { NavigationDrawerItemGroup } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemGroup';
|
||||
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
|
||||
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
|
||||
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
|
||||
import { styled } from '@linaria/react';
|
||||
import { matchPath, resolvePath, useLocation } from 'react-router-dom';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
|
||||
const StyledSectionsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const renderSectionItem = (
|
||||
item: SettingsNavigationItem,
|
||||
index: number,
|
||||
section: SettingsNavigationSection,
|
||||
getSelectedIndexForSubItems: (subItems: SettingsNavigationItem[]) => number,
|
||||
) => {
|
||||
const subItems = item.subItems;
|
||||
if (Array.isArray(subItems) && subItems.length > 0) {
|
||||
const selectedSubItemIndex = getSelectedIndexForSubItems(subItems);
|
||||
const hasActiveSubItem = selectedSubItemIndex !== -1;
|
||||
|
||||
return (
|
||||
<NavigationDrawerItemGroup key={item.path || `group-${index}`}>
|
||||
<SettingsNavigationDrawerItem
|
||||
item={item}
|
||||
hasActiveSubItem={hasActiveSubItem}
|
||||
subItemState={
|
||||
item.indentationLevel
|
||||
? getNavigationSubItemLeftAdornment({
|
||||
arrayLength: section.items.length,
|
||||
index,
|
||||
selectedIndex: selectedSubItemIndex,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{subItems.map((subItem, subIndex) => (
|
||||
<SettingsNavigationDrawerItem
|
||||
key={subItem.path || `subitem-${subIndex}`}
|
||||
item={subItem}
|
||||
subItemState={
|
||||
subItem.indentationLevel
|
||||
? getNavigationSubItemLeftAdornment({
|
||||
arrayLength: subItems.length,
|
||||
index: subIndex,
|
||||
selectedIndex: selectedSubItemIndex,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</NavigationDrawerItemGroup>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingsNavigationDrawerItem
|
||||
key={item.path || `item-${index}`}
|
||||
item={item}
|
||||
subItemState={
|
||||
item.indentationLevel
|
||||
? getNavigationSubItemLeftAdornment({
|
||||
arrayLength: section.items.length,
|
||||
index,
|
||||
selectedIndex: index,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsNavigationDrawerItems = () => {
|
||||
const settingsNavigationItems: SettingsNavigationSection[] =
|
||||
useSettingsNavigationItems();
|
||||
@@ -34,7 +102,7 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledSectionsContainer>
|
||||
{settingsNavigationItems.map((section) => {
|
||||
const allItemsHidden = section.items.every((item) => item.isHidden);
|
||||
if (allItemsHidden) {
|
||||
@@ -42,75 +110,31 @@ export const SettingsNavigationDrawerItems = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationDrawerSection key={section.label}>
|
||||
{section.isAdvanced ? (
|
||||
<AdvancedSettingsWrapper hideDot>
|
||||
<NavigationDrawerSectionTitle label={section.label} />
|
||||
</AdvancedSettingsWrapper>
|
||||
) : (
|
||||
<NavigationDrawerSectionTitle label={section.label} />
|
||||
<CollapsibleNavigationDrawerSection
|
||||
key={section.label}
|
||||
sectionId={`settings/${section.label}`}
|
||||
label={section.label}
|
||||
wrapTitle={
|
||||
section.isAdvanced
|
||||
? (titleNode) => (
|
||||
<AdvancedSettingsWrapper hideDot>
|
||||
{titleNode}
|
||||
</AdvancedSettingsWrapper>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{section.items.map((item, index) =>
|
||||
renderSectionItem(
|
||||
item,
|
||||
index,
|
||||
section,
|
||||
getSelectedIndexForSubItems,
|
||||
),
|
||||
)}
|
||||
{section.items.map((item, index) => {
|
||||
const subItems = item.subItems;
|
||||
if (Array.isArray(subItems) && subItems.length > 0) {
|
||||
const selectedSubItemIndex =
|
||||
getSelectedIndexForSubItems(subItems);
|
||||
const hasActiveSubItem = selectedSubItemIndex !== -1;
|
||||
|
||||
return (
|
||||
<NavigationDrawerItemGroup
|
||||
key={item.path || `group-${index}`}
|
||||
>
|
||||
<SettingsNavigationDrawerItem
|
||||
item={item}
|
||||
hasActiveSubItem={hasActiveSubItem}
|
||||
subItemState={
|
||||
item.indentationLevel
|
||||
? getNavigationSubItemLeftAdornment({
|
||||
arrayLength: section.items.length,
|
||||
index,
|
||||
selectedIndex: selectedSubItemIndex,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{subItems.map((subItem, subIndex) => (
|
||||
<SettingsNavigationDrawerItem
|
||||
key={subItem.path || `subitem-${subIndex}`}
|
||||
item={subItem}
|
||||
subItemState={
|
||||
subItem.indentationLevel
|
||||
? getNavigationSubItemLeftAdornment({
|
||||
arrayLength: subItems.length,
|
||||
index: subIndex,
|
||||
selectedIndex: selectedSubItemIndex,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</NavigationDrawerItemGroup>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingsNavigationDrawerItem
|
||||
key={item.path || `item-${index}`}
|
||||
item={item}
|
||||
subItemState={
|
||||
item.indentationLevel
|
||||
? getNavigationSubItemLeftAdornment({
|
||||
arrayLength: section.items.length,
|
||||
index,
|
||||
selectedIndex: index,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NavigationDrawerSection>
|
||||
</CollapsibleNavigationDrawerSection>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</StyledSectionsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { useScrollRestoration } from '@/ui/utilities/scroll/hooks/useScrollRestoration';
|
||||
@@ -13,6 +12,7 @@ const StyledSettingsPageContainer = styled.div<{
|
||||
width?: number;
|
||||
isMobile?: boolean;
|
||||
}>`
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[8]};
|
||||
@@ -27,7 +27,7 @@ const StyledSettingsPageContainer = styled.div<{
|
||||
if (isMobile) {
|
||||
return 'unset';
|
||||
}
|
||||
return OBJECT_SETTINGS_WIDTH + 'px';
|
||||
return '100%';
|
||||
}};
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { Fragment, useContext } from 'react';
|
||||
import { type IconComponent } from 'twenty-ui/display';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
export type SettingsStatRow = {
|
||||
Icon: IconComponent;
|
||||
label: string;
|
||||
// String so callers can render a placeholder (e.g. "—") while async counts
|
||||
// are still loading. Layout stats just pass `count.toString()`.
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SettingsStatsGridProps = {
|
||||
// Each inner array is one column rendered top-to-bottom; columns are
|
||||
// separated by a vertical divider. Pass [[a, b], [c, d]] for a 2x2 layout
|
||||
// or [[a, b, c]] for a single column.
|
||||
columns: SettingsStatRow[][];
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
background: ${themeCssVariables.background.secondary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
padding: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledColumn = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const StyledDivider = styled.div`
|
||||
align-self: stretch;
|
||||
background: ${themeCssVariables.border.color.light};
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
const StyledRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
height: ${themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
flex: 1 1 0;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledValue = styled.div`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
padding: 0 ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
type StatRowProps = SettingsStatRow;
|
||||
|
||||
const StatRow = ({ Icon, label, value }: StatRowProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
return (
|
||||
<StyledRow>
|
||||
<Icon size={theme.icon.size.md} color={theme.font.color.tertiary} />
|
||||
<StyledLabel>{label}</StyledLabel>
|
||||
<StyledValue>{value}</StyledValue>
|
||||
</StyledRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsStatsGrid = ({ columns }: SettingsStatsGridProps) => (
|
||||
<StyledContainer>
|
||||
{columns.map((column, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && <StyledDivider />}
|
||||
<StyledColumn>
|
||||
{column.map((stat) => (
|
||||
<StatRow
|
||||
key={stat.label}
|
||||
Icon={stat.Icon}
|
||||
label={stat.label}
|
||||
value={stat.value}
|
||||
/>
|
||||
))}
|
||||
</StyledColumn>
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 78 KiB |
@@ -3,12 +3,13 @@ import { styled } from '@linaria/react';
|
||||
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import React from 'react';
|
||||
|
||||
// Column width used by the Applications data tables (Instances column).
|
||||
export const SETTINGS_OBJECT_TABLE_COLUMN_WIDTH = '98.7px';
|
||||
|
||||
const SETTINGS_OBJECT_TABLE_APP_COLUMN_WIDTH = '140px';
|
||||
const SETTINGS_OBJECT_TABLE_FIELDS_COLUMN_WIDTH = '72px';
|
||||
|
||||
export const SETTINGS_OBJECT_TABLE_ROW_GRID_TEMPLATE_COLUMNS = `180px ${SETTINGS_OBJECT_TABLE_APP_COLUMN_WIDTH} ${SETTINGS_OBJECT_TABLE_FIELDS_COLUMN_WIDTH} ${SETTINGS_OBJECT_TABLE_COLUMN_WIDTH} 36px`;
|
||||
// Relative grid: Name takes all remaining space (with a floor); App / Fields /
|
||||
// Instances get fixed minimums so short text columns don't collapse; trailing
|
||||
// 36 px holds the chevron / action cell.
|
||||
export const SETTINGS_OBJECT_TABLE_ROW_GRID_TEMPLATE_COLUMNS = `minmax(180px, 1fr) 140px 80px 100px 36px`;
|
||||
|
||||
export const SETTINGS_OBJECT_TABLE_ROW_MOBILE_MIN_WIDTH = '520px';
|
||||
|
||||
|
||||
@@ -58,7 +58,10 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
enterLayoutCustomizationMode();
|
||||
// Skip navigation when entry was blocked (e.g. a dashboard is mid-edit).
|
||||
if (!enterLayoutCustomizationMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateApp(AppPath.RecordShowPage, {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext } from 'react';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { IconEye } from 'twenty-ui/display';
|
||||
import { FloatingButton } from 'twenty-ui/input';
|
||||
|
||||
import DarkCoverImage from '@/settings/data-model/assets/cover-dark.png';
|
||||
import LightCoverImage from '@/settings/data-model/assets/cover-light.png';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledCoverImageContainer = styled.div`
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
margin-bottom: ${themeCssVariables.spacing[8]};
|
||||
min-height: 153px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledCoverImage = styled.img`
|
||||
display: block;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledButtonOverlay = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
|
||||
& > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
export const SettingsObjectCoverImage = () => {
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<StyledCoverImageContainer>
|
||||
<StyledCoverImage
|
||||
src={
|
||||
colorScheme === 'light'
|
||||
? LightCoverImage.toString()
|
||||
: DarkCoverImage.toString()
|
||||
}
|
||||
alt=""
|
||||
aria-hidden
|
||||
/>
|
||||
<StyledButtonOverlay>
|
||||
<FloatingButton
|
||||
Icon={IconEye}
|
||||
title={t`Visualize`}
|
||||
size="small"
|
||||
to={getSettingsPath(SettingsPath.ObjectOverview)}
|
||||
/>
|
||||
</StyledButtonOverlay>
|
||||
</StyledCoverImageContainer>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { useContext } from 'react';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type Webhook } from '~/generated-metadata/graphql';
|
||||
|
||||
const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px';
|
||||
export const WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS = '1fr 28px';
|
||||
|
||||
const StyledIconChevronRightContainer = styled.span`
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { SettingsDevelopersWebhookTableRow } from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
|
||||
import {
|
||||
SettingsDevelopersWebhookTableRow,
|
||||
WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS,
|
||||
} from '@/settings/developers/components/SettingsDevelopersWebhookTableRow';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
@@ -24,7 +27,7 @@ export const SettingsWebhooksTable = () => {
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableRow gridTemplateColumns="444px 68px">
|
||||
<TableRow gridTemplateColumns={WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS}>
|
||||
<TableHeader>URL</TableHeader>
|
||||
<TableHeader></TableHeader>
|
||||
</TableRow>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
IconHelpCircle,
|
||||
IconHierarchy2,
|
||||
IconKey,
|
||||
IconLayout,
|
||||
IconMail,
|
||||
IconMessage,
|
||||
IconPlug,
|
||||
@@ -124,20 +125,18 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
Icon: IconSettings,
|
||||
isHidden: !permissionMap[PermissionFlagType.WORKSPACE],
|
||||
},
|
||||
{
|
||||
label: t`Email`,
|
||||
path: SettingsPath.WorkspaceEmail,
|
||||
Icon: IconMail,
|
||||
isHidden:
|
||||
!isEmailGroupFeatureEnabled ||
|
||||
!permissionMap[PermissionFlagType.WORKSPACE],
|
||||
},
|
||||
{
|
||||
label: t`Data model`,
|
||||
path: SettingsPath.Objects,
|
||||
Icon: IconHierarchy2,
|
||||
isHidden: !permissionMap[PermissionFlagType.DATA_MODEL],
|
||||
},
|
||||
{
|
||||
label: t`Layout`,
|
||||
path: SettingsPath.Layout,
|
||||
Icon: IconLayout,
|
||||
isHidden: !permissionMap[PermissionFlagType.LAYOUTS],
|
||||
},
|
||||
{
|
||||
label: t`Members`,
|
||||
path: SettingsPath.WorkspaceMembersPage,
|
||||
@@ -175,9 +174,17 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
label: t`AI`,
|
||||
path: SettingsPath.AI,
|
||||
Icon: IconSparkles,
|
||||
isHidden: !permissionMap[PermissionFlagType.WORKSPACE],
|
||||
isHidden: !permissionMap[PermissionFlagType.AI],
|
||||
modifier: 'new',
|
||||
},
|
||||
{
|
||||
label: t`Email`,
|
||||
path: SettingsPath.WorkspaceEmail,
|
||||
Icon: IconMail,
|
||||
isHidden:
|
||||
!isEmailGroupFeatureEnabled ||
|
||||
!permissionMap[PermissionFlagType.WORKSPACE],
|
||||
},
|
||||
{
|
||||
label: t`Security`,
|
||||
path: SettingsPath.Security,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
|
||||
import { frontComponentsSelector } from '@/front-components/states/frontComponentsSelector';
|
||||
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
|
||||
import { pageLayoutsWithRelationsSelector } from '@/page-layout/states/pageLayoutsWithRelationsSelector';
|
||||
import { SettingsStatsGrid } from '@/settings/components/SettingsStatsGrid';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
IconAppWindow,
|
||||
IconCommand,
|
||||
IconLayoutSidebarLeftExpand,
|
||||
IconPuzzle,
|
||||
IconTable,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
export const SettingsLayoutItemsStats = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
|
||||
const navigationMenuItems = useAtomStateValue(navigationMenuItemsSelector);
|
||||
const views = useAtomStateValue(viewsSelector);
|
||||
const pageLayoutsWithRelations = useAtomStateValue(
|
||||
pageLayoutsWithRelationsSelector,
|
||||
);
|
||||
const frontComponents = useAtomStateValue(frontComponentsSelector);
|
||||
|
||||
return (
|
||||
<SettingsStatsGrid
|
||||
columns={[
|
||||
[
|
||||
{
|
||||
Icon: IconCommand,
|
||||
label: t`Commands`,
|
||||
value: commandMenuItems.length.toString(),
|
||||
},
|
||||
{
|
||||
Icon: IconLayoutSidebarLeftExpand,
|
||||
label: t`Sidebar items`,
|
||||
value: navigationMenuItems.length.toString(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
Icon: IconTable,
|
||||
label: t`Views`,
|
||||
value: views.length.toString(),
|
||||
},
|
||||
{
|
||||
Icon: IconAppWindow,
|
||||
label: t`Pages`,
|
||||
value: pageLayoutsWithRelations.length.toString(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
Icon: IconPuzzle,
|
||||
label: t`Widgets`,
|
||||
value: frontComponents.length.toString(),
|
||||
},
|
||||
],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 290 KiB |
@@ -1,4 +1,7 @@
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import {
|
||||
isPlaygroundApiKeyFresh,
|
||||
playgroundApiKeyState,
|
||||
} from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -40,7 +43,7 @@ export const GraphQLPlayground = ({
|
||||
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
|
||||
if (!playgroundApiKey) {
|
||||
if (!isPlaygroundApiKeyFresh(playgroundApiKey)) {
|
||||
onError();
|
||||
return null;
|
||||
}
|
||||
@@ -60,7 +63,7 @@ export const GraphQLPlayground = ({
|
||||
plugins={[explorer]}
|
||||
fetcher={fetcher}
|
||||
defaultHeaders={JSON.stringify({
|
||||
Authorization: `Bearer ${playgroundApiKey}`,
|
||||
Authorization: `Bearer ${playgroundApiKey.token}`,
|
||||
})}
|
||||
/>
|
||||
</StyledGraphiQLContainer>
|
||||
|
||||
@@ -1,139 +1,61 @@
|
||||
import { useOpenPlayground } from '@/settings/playground/hooks/useOpenPlayground';
|
||||
import { SETTINGS_PLAYGROUND_FORM_SCHEMA_SELECT_OPTIONS } from '@/settings/playground/constants/SettingsPlaygroundFormSchemaSelectOptions';
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { styled } from '@linaria/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { CustomError } from 'twenty-shared/utils';
|
||||
import { IconApi, IconBrandGraphql } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { z } from 'zod';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
const playgroundSetupFormSchema = z.object({
|
||||
apiKeyForPlayground: z.string(),
|
||||
schema: z.enum(PlaygroundSchemas),
|
||||
playgroundType: z.enum(PlaygroundTypes),
|
||||
});
|
||||
|
||||
type PlaygroundSetupFormValues = z.infer<typeof playgroundSetupFormSchema>;
|
||||
|
||||
// Last column shrinks to the Launch button's content width so its right
|
||||
// edge sits at the form's right edge. The two select columns share the
|
||||
// remaining space equally.
|
||||
const StyledForm = styled.form`
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
grid-template-columns: 1.5fr 1fr 1fr 0.5fr;
|
||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const PlaygroundSetupForm = () => {
|
||||
const { t } = useLingui();
|
||||
const navigateSettings = useNavigateSettings();
|
||||
const [playgroundApiKey, setPlaygroundApiKey] = useAtomState(
|
||||
playgroundApiKeyState,
|
||||
);
|
||||
const openPlayground = useOpenPlayground();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setError,
|
||||
} = useForm<PlaygroundSetupFormValues>({
|
||||
mode: 'onTouched',
|
||||
resolver: zodResolver(playgroundSetupFormSchema),
|
||||
defaultValues: {
|
||||
schema: PlaygroundSchemas.CORE,
|
||||
playgroundType: PlaygroundTypes.REST,
|
||||
apiKeyForPlayground: playgroundApiKey || '',
|
||||
},
|
||||
});
|
||||
|
||||
const validateApiKey = async (values: PlaygroundSetupFormValues) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${REACT_APP_SERVER_BASE_URL}/rest/open-api/${values.schema}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${values.apiKeyForPlayground}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new CustomError(
|
||||
`HTTP error! status: ${response.status}`,
|
||||
'HTTP_ERROR',
|
||||
);
|
||||
}
|
||||
|
||||
const openAPIReference = await response.json();
|
||||
|
||||
if (!openAPIReference.tags) {
|
||||
throw new Error('Invalid API Key');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
throw new Error(t`Invalid API key`);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: PlaygroundSetupFormValues) => {
|
||||
try {
|
||||
await validateApiKey(values);
|
||||
|
||||
setPlaygroundApiKey(values.apiKeyForPlayground);
|
||||
|
||||
const path =
|
||||
values.playgroundType === PlaygroundTypes.GRAPHQL
|
||||
? SettingsPath.GraphQLPlayground
|
||||
: SettingsPath.RestPlayground;
|
||||
|
||||
navigateSettings(path, {
|
||||
schema: values.schema.toLowerCase(),
|
||||
});
|
||||
} catch (error) {
|
||||
setError('apiKeyForPlayground', {
|
||||
type: 'manual',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t`An unexpected error occurred`,
|
||||
});
|
||||
}
|
||||
await openPlayground(values.playgroundType, values.schema);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledForm onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
name="apiKeyForPlayground"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<SettingsTextInput
|
||||
instanceId="playground-api-key"
|
||||
label={t`API Key`}
|
||||
placeholder={t`Enter your API key`}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
setPlaygroundApiKey(newValue);
|
||||
}}
|
||||
error={error?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="schema"
|
||||
control={control}
|
||||
defaultValue={PlaygroundSchemas.CORE}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
dropdownId="schema"
|
||||
@@ -152,17 +74,12 @@ export const PlaygroundSetupForm = () => {
|
||||
<Controller
|
||||
name="playgroundType"
|
||||
control={control}
|
||||
defaultValue={PlaygroundTypes.REST}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
dropdownId="apiPlaygroundType"
|
||||
label={t`API`}
|
||||
options={[
|
||||
{
|
||||
value: PlaygroundTypes.REST,
|
||||
label: t`REST`,
|
||||
Icon: IconApi,
|
||||
},
|
||||
{ value: PlaygroundTypes.REST, label: t`REST`, Icon: IconApi },
|
||||
{
|
||||
value: PlaygroundTypes.GRAPHQL,
|
||||
label: t`GraphQL`,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { RestPlaygroundSchemaFetchEffect } from '@/settings/playground/components/RestPlaygroundSchemaFetchEffect';
|
||||
import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import {
|
||||
isPlaygroundApiKeyFresh,
|
||||
playgroundApiKeyState,
|
||||
} from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useContext, useState, lazy, Suspense } from 'react';
|
||||
@@ -55,7 +58,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
|
||||
const playgroundApiKey = useAtomStateValue(playgroundApiKeyState);
|
||||
const [specContent, setSpecContent] = useState<object | null>(null);
|
||||
|
||||
if (!playgroundApiKey) {
|
||||
if (!isPlaygroundApiKeyFresh(playgroundApiKey)) {
|
||||
onError();
|
||||
return null;
|
||||
}
|
||||
@@ -74,7 +77,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
|
||||
<StyledContainer>
|
||||
<RestPlaygroundSchemaFetchEffect
|
||||
schema={schema}
|
||||
apiKey={playgroundApiKey}
|
||||
apiKey={playgroundApiKey.token}
|
||||
onSchemaLoaded={setSpecContent}
|
||||
onError={onError}
|
||||
/>
|
||||
@@ -89,7 +92,7 @@ export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => {
|
||||
},
|
||||
authentication: {
|
||||
http: {
|
||||
bearer: { token: playgroundApiKey },
|
||||
bearer: { token: playgroundApiKey.token },
|
||||
},
|
||||
},
|
||||
baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { H2Title, IconCopy } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Card, CardContent, Section } from 'twenty-ui/layout';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
const StyledPre = styled.pre`
|
||||
font-family: monospace;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
`;
|
||||
|
||||
const buildMcpConfig = (serverUrl: string) =>
|
||||
`{
|
||||
"mcpServers": {
|
||||
"twenty": {
|
||||
"url": "${serverUrl}/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <YOUR_API_KEY>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
export const SettingsMcpSetup = () => {
|
||||
const { t } = useLingui();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const mcpConfig = buildMcpConfig(REACT_APP_SERVER_BASE_URL);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Connect your AI assistant`}
|
||||
description={t`Add Twenty as a Model Context Protocol (MCP) server. Paste this config into Claude Desktop, Cursor, Cline, Continue, Zed, or any other MCP-aware client.`}
|
||||
/>
|
||||
<Card rounded>
|
||||
<CardContent divider>
|
||||
<StyledPre>{mcpConfig}</StyledPre>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<Button
|
||||
title={t`Copy config`}
|
||||
Icon={IconCopy}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
copyToClipboard(mcpConfig, t`MCP config copied to clipboard`)
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { type ReactNode, useContext } from 'react';
|
||||
|
||||
import DarkCoverImage from '@/settings/playground/assets/cover-dark.png';
|
||||
import LightCoverImage from '@/settings/playground/assets/cover-light.png';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledCoverContainer = styled.div`
|
||||
align-items: center;
|
||||
background-size: cover;
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 153px;
|
||||
justify-content: center;
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
margin-top: ${themeCssVariables.spacing[4]};
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
type StyledSettingsApiPlaygroundCoverImageProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const StyledSettingsApiPlaygroundCoverImage = ({
|
||||
children,
|
||||
className,
|
||||
}: StyledSettingsApiPlaygroundCoverImageProps) => {
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
|
||||
const coverImage =
|
||||
colorScheme === 'light'
|
||||
? LightCoverImage.toString()
|
||||
: DarkCoverImage.toString();
|
||||
return (
|
||||
<StyledCoverContainer
|
||||
className={className}
|
||||
style={{ backgroundImage: `url('${coverImage}')` }}
|
||||
>
|
||||
{children}
|
||||
</StyledCoverContainer>
|
||||
);
|
||||
};
|
||||
@@ -14,7 +14,10 @@ const PlaygroundApiKeySetterEffect = () => {
|
||||
const setPlaygroundApiKey = useSetAtomState(playgroundApiKeyState);
|
||||
|
||||
useEffect(() => {
|
||||
setPlaygroundApiKey('test-api-key-123');
|
||||
setPlaygroundApiKey({
|
||||
token: 'test-api-key-123',
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
}, [setPlaygroundApiKey]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -12,7 +12,10 @@ const PlaygroundApiKeySetterEffect = () => {
|
||||
const setPlaygroundApiKey = useSetAtomState(playgroundApiKeyState);
|
||||
|
||||
useEffect(() => {
|
||||
setPlaygroundApiKey('test-api-key-123');
|
||||
setPlaygroundApiKey({
|
||||
token: 'test-api-key-123',
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
}, [setPlaygroundApiKey]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { useCallback } from 'react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
isPlaygroundApiKeyFresh,
|
||||
playgroundApiKeyState,
|
||||
} from '@/settings/playground/states/playgroundApiKeyState';
|
||||
import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas';
|
||||
import { PlaygroundTypes } from '@/settings/playground/types/PlaygroundTypes';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { GeneratePlaygroundTokenDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
// Re-mint when less than this remains so the user never lands on a token about to expire.
|
||||
const TOKEN_FRESHNESS_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
export const useOpenPlayground = () => {
|
||||
const navigateSettings = useNavigateSettings();
|
||||
const [playgroundApiKey, setPlaygroundApiKey] = useAtomState(
|
||||
playgroundApiKeyState,
|
||||
);
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [generatePlaygroundToken] = useMutation(
|
||||
GeneratePlaygroundTokenDocument,
|
||||
{
|
||||
onError: () => {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Could not open the API playground`,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
async (type: PlaygroundTypes, schema: PlaygroundSchemas) => {
|
||||
if (
|
||||
!isPlaygroundApiKeyFresh(playgroundApiKey, TOKEN_FRESHNESS_BUFFER_MS)
|
||||
) {
|
||||
const { data } = await generatePlaygroundToken();
|
||||
const mintedToken = data?.generatePlaygroundToken;
|
||||
if (!isDefined(mintedToken)) return;
|
||||
setPlaygroundApiKey(mintedToken);
|
||||
}
|
||||
|
||||
const path =
|
||||
type === PlaygroundTypes.GRAPHQL
|
||||
? SettingsPath.GraphQLPlayground
|
||||
: SettingsPath.RestPlayground;
|
||||
navigateSettings(path, { schema });
|
||||
},
|
||||
[
|
||||
playgroundApiKey,
|
||||
generatePlaygroundToken,
|
||||
navigateSettings,
|
||||
setPlaygroundApiKey,
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,21 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type AuthToken } from '~/generated-metadata/graphql';
|
||||
|
||||
export const playgroundApiKeyState = createAtomState<string | null>({
|
||||
// In-memory only: a short-lived, full-permission bearer token. Keeping it out of
|
||||
// localStorage bounds the exfiltration window to the current tab and leaves no
|
||||
// usable credential at rest after the tab closes.
|
||||
export const playgroundApiKeyState = createAtomState<AuthToken | null>({
|
||||
key: 'playgroundApiKeyState',
|
||||
defaultValue: null,
|
||||
useLocalStorage: true,
|
||||
});
|
||||
|
||||
// Usable only while it stays valid for at least `bufferMs` longer. Consumers pass
|
||||
// no buffer (reject the moment it expires); the launcher passes a buffer so it
|
||||
// re-mints before a near-expired token can fail mid-session.
|
||||
export const isPlaygroundApiKeyFresh = (
|
||||
token: AuthToken | null,
|
||||
bufferMs = 0,
|
||||
): token is AuthToken =>
|
||||
isDefined(token) &&
|
||||
new Date(token.expiresAt).getTime() - Date.now() > bufferMs;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext } from 'react';
|
||||
import { IconPlayerPlay } from 'twenty-ui/display';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type HeroPlayButtonProps = {
|
||||
onClick?: () => void;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const StyledButton = styled.button`
|
||||
align-items: center;
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: ${themeCssVariables.boxShadow.strong};
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
height: 44px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform 120ms ease-out,
|
||||
background-color 120ms ease-out;
|
||||
width: 44px;
|
||||
|
||||
&:hover {
|
||||
background: ${themeCssVariables.background.secondary};
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${themeCssVariables.border.color.blue};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const HeroPlayButton = ({
|
||||
onClick,
|
||||
ariaLabel = 'Play video',
|
||||
className,
|
||||
}: HeroPlayButtonProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
>
|
||||
<IconPlayerPlay size={theme.icon.size.md} stroke={theme.icon.stroke.md} />
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
@@ -9,10 +9,8 @@ import { LayoutCustomizationBar } from '@/layout-customization/components/Layout
|
||||
import { AppNavigationDrawer } from '@/navigation/components/AppNavigationDrawer';
|
||||
import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar';
|
||||
import { PageDragDropProvider } from '@/navigation-menu-item/display/dnd/providers/PageDragDropProvider';
|
||||
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
|
||||
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
|
||||
import { BackgroundMockNavigationDrawer } from '@/sign-in-background-mock/components/BackgroundMockNavigationDrawer';
|
||||
import { Suspense, lazy, useContext } from 'react';
|
||||
import { Suspense, lazy } from 'react';
|
||||
|
||||
const BackgroundMockPage = lazy(() =>
|
||||
import('@/sign-in-background-mock/components/BackgroundMockPage').then(
|
||||
@@ -21,13 +19,11 @@ const BackgroundMockPage = lazy(() =>
|
||||
);
|
||||
import { useShowFullscreen } from '@/ui/layout/fullscreen/hooks/useShowFullscreen';
|
||||
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
|
||||
import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { styled } from '@linaria/react';
|
||||
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
|
||||
import { AnimatePresence, LayoutGroup } from 'framer-motion';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useScreenSize } from 'twenty-ui/utilities';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
const StyledLayout = styled.div`
|
||||
background: ${themeCssVariables.background.noisy};
|
||||
display: flex;
|
||||
@@ -43,14 +39,13 @@ const StyledLayout = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledPageContainerBase = styled.div`
|
||||
const StyledPageContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
`;
|
||||
const StyledPageContainer = motion.create(StyledPageContainerBase);
|
||||
|
||||
const StyledNavigationDrawerWrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
@@ -65,11 +60,8 @@ const StyledMainContainer = styled.div`
|
||||
|
||||
export const DefaultLayout = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const isSettingsPage = useIsSettingsPage();
|
||||
const windowsWidth = useScreenSize().width;
|
||||
const showAuthModal = useShowAuthModal();
|
||||
const useShowFullScreen = useShowFullscreen();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,21 +70,7 @@ export const DefaultLayout = () => {
|
||||
<AppErrorBoundary FallbackComponent={AppFullScreenErrorFallback}>
|
||||
<InformationBannerIsImpersonating />
|
||||
<LayoutCustomizationBar />
|
||||
<StyledPageContainer
|
||||
animate={{
|
||||
marginLeft:
|
||||
isSettingsPage && !isMobile && !useShowFullScreen
|
||||
? (windowsWidth -
|
||||
(OBJECT_SETTINGS_WIDTH +
|
||||
NAVIGATION_DRAWER_CONSTRAINTS.default +
|
||||
76)) /
|
||||
2
|
||||
: 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.normal,
|
||||
}}
|
||||
>
|
||||
<StyledPageContainer>
|
||||
<PageDragDropProvider>
|
||||
{!showAuthModal && <KeyboardShortcutMenu />}
|
||||
{showAuthModal ? (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
|
||||
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
|
||||
import {
|
||||
Breadcrumb,
|
||||
type BreadcrumbProps,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { styled } from '@linaria/react';
|
||||
import { type JSX, type ReactNode } from 'react';
|
||||
import { PageBody } from './PageBody';
|
||||
import { PageHeader } from './PageHeader';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
@@ -20,12 +20,31 @@ type SubMenuTopBarContainerProps = {
|
||||
tag?: JSX.Element;
|
||||
};
|
||||
|
||||
// Cards, forms, and tables inside the white panel are centered in a fixed
|
||||
// max-width column so they don't sprawl on large displays. The white panel
|
||||
// itself spans edge-to-edge; only the content is constrained.
|
||||
const SETTINGS_CONTENT_MAX_WIDTH = 760;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
// flex: 1 + min-height: 0 keep the vertical-scroll chain intact: PagePanel's
|
||||
// own overflow handling sits one level up and depends on its children
|
||||
// participating in the flex height calculation rather than collapsing to
|
||||
// content height.
|
||||
const StyledBodyContentWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
max-width: ${SETTINGS_CONTENT_MAX_WIDTH}px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.span<{ reserveTitleSpace?: boolean }>`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
display: flex;
|
||||
@@ -53,16 +72,24 @@ export const SubMenuTopBarContainer = ({
|
||||
<PageHeader title={<Breadcrumb links={links} />}>
|
||||
{actionButton}
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<InformationBannerWrapper />
|
||||
{(isDefined(title) || reserveTitleSpace === true) && (
|
||||
<StyledTitle reserveTitleSpace={reserveTitleSpace}>
|
||||
{title}
|
||||
{tag}
|
||||
</StyledTitle>
|
||||
)}
|
||||
{children}
|
||||
</PageBody>
|
||||
{/*
|
||||
MainContainerLayoutWithSidePanel is the same wrapper the App's record
|
||||
pages use: it renders the page body on the left and SidePanelForDesktop
|
||||
on the right. Hosting it here lets the AI chat side panel (and any
|
||||
other side-panel page) open in settings exactly as it does in the App.
|
||||
*/}
|
||||
<MainContainerLayoutWithSidePanel>
|
||||
<StyledBodyContentWrapper>
|
||||
<InformationBannerWrapper />
|
||||
{(isDefined(title) || reserveTitleSpace === true) && (
|
||||
<StyledTitle reserveTitleSpace={reserveTitleSpace}>
|
||||
{title}
|
||||
{tag}
|
||||
</StyledTitle>
|
||||
)}
|
||||
{children}
|
||||
</StyledBodyContentWrapper>
|
||||
</MainContainerLayoutWithSidePanel>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
|
||||
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
|
||||
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
|
||||
import { type ReactNode } from 'react';
|
||||
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
|
||||
|
||||
type CollapsibleNavigationDrawerSectionProps = {
|
||||
// Unique id used to persist the open/closed state in localStorage. Pass
|
||||
// a namespaced value (e.g. 'settings/User') so unrelated sections in
|
||||
// different drawers don't share state.
|
||||
sectionId: string;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
// Optional wrapper around the section title (e.g. AdvancedSettingsWrapper
|
||||
// for advanced-mode-only sections). Receives the title node and returns
|
||||
// the wrapped node.
|
||||
wrapTitle?: (titleNode: ReactNode) => ReactNode;
|
||||
};
|
||||
|
||||
// One-stop section component for any drawer that wants the main-app's
|
||||
// collapsible section behavior: click the title to collapse / expand,
|
||||
// animated height transition, persisted open state, chevron-on-hover.
|
||||
// Use this instead of stitching together NavigationDrawerSection +
|
||||
// NavigationDrawerSectionTitle + AnimatedExpandableContainer by hand at
|
||||
// every call site.
|
||||
export const CollapsibleNavigationDrawerSection = ({
|
||||
sectionId,
|
||||
label,
|
||||
children,
|
||||
wrapTitle,
|
||||
}: CollapsibleNavigationDrawerSectionProps) => {
|
||||
const { toggleNavigationSection, isNavigationSectionOpen } =
|
||||
useNavigationSection(sectionId);
|
||||
|
||||
const titleNode = (
|
||||
<NavigationDrawerSectionTitle
|
||||
label={label}
|
||||
onClick={toggleNavigationSection}
|
||||
isOpen={isNavigationSectionOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<NavigationDrawerSection>
|
||||
{wrapTitle ? wrapTitle(titleNode) : titleNode}
|
||||
<AnimatedExpandableContainer
|
||||
isExpanded={isNavigationSectionOpen}
|
||||
dimension="height"
|
||||
mode="fit-content"
|
||||
containAnimation
|
||||
initial={false}
|
||||
>
|
||||
{children}
|
||||
</AnimatedExpandableContainer>
|
||||
</NavigationDrawerSection>
|
||||
);
|
||||
};
|
||||
@@ -52,7 +52,6 @@ const StyledAnimatedContainer = styled.div<{
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div<{
|
||||
isSettings?: boolean;
|
||||
isExpanded?: boolean;
|
||||
}>`
|
||||
box-sizing: border-box;
|
||||
@@ -60,10 +59,8 @@ const StyledContainer = styled.div<{
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[3]};
|
||||
height: 100%;
|
||||
padding: ${({ isSettings }) =>
|
||||
isSettings
|
||||
? `${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} 0`
|
||||
: `${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[2]}`};
|
||||
padding: ${themeCssVariables.spacing[3]} 0 ${themeCssVariables.spacing[4]}
|
||||
${themeCssVariables.spacing[2]};
|
||||
width: ${({ isExpanded }) =>
|
||||
isExpanded ? `var(${NAVIGATION_DRAWER_WIDTH_VAR})` : '100%'};
|
||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||
@@ -123,7 +120,7 @@ export const NavigationDrawer = ({
|
||||
isExpanded={isExpanded}
|
||||
isResizing={isResizing}
|
||||
>
|
||||
<StyledContainer isSettings={isSettingsDrawer} isExpanded={isExpanded}>
|
||||
<StyledContainer isExpanded={isExpanded}>
|
||||
{!isMobile && isSettingsDrawer && title ? (
|
||||
<NavigationDrawerBackButton title={title} />
|
||||
) : (
|
||||
|
||||
@@ -42,7 +42,6 @@ const StyledContainer = styled.div`
|
||||
flex-direction: row;
|
||||
height: ${themeCssVariables.spacing[8]};
|
||||
justify-content: space-between;
|
||||
padding-left: ${themeCssVariables.spacing[5]};
|
||||
`;
|
||||
|
||||
export const NavigationDrawerBackButton = ({
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { useIsSettingsDrawer } from '@/navigation/hooks/useIsSettingsDrawer';
|
||||
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledFixedContainer = styled.div<{
|
||||
isSettings?: boolean;
|
||||
isMobile?: boolean;
|
||||
}>`
|
||||
padding-left: ${({ isSettings, isMobile }) =>
|
||||
isSettings || isMobile ? themeCssVariables.spacing[5] : '0'};
|
||||
padding-right: ${({ isSettings, isMobile }) =>
|
||||
isMobile
|
||||
? themeCssVariables.spacing[5]
|
||||
: isSettings
|
||||
? themeCssVariables.spacing[8]
|
||||
: '0'};
|
||||
// Mobile keeps the touch-friendly horizontal padding; on desktop the container
|
||||
// is edge-to-edge and the child supplies its own padding.
|
||||
const StyledFixedContainer = styled.div<{ isMobile?: boolean }>`
|
||||
padding-left: ${({ isMobile }) =>
|
||||
isMobile ? themeCssVariables.spacing[5] : '0'};
|
||||
padding-right: ${({ isMobile }) =>
|
||||
isMobile ? themeCssVariables.spacing[5] : '0'};
|
||||
`;
|
||||
|
||||
export const NavigationDrawerFixedContent = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const isSettingsDrawer = useIsSettingsDrawer();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<StyledFixedContainer isSettings={isSettingsDrawer} isMobile={isMobile}>
|
||||
<StyledFixedContainer isMobile={isMobile}>
|
||||
<NavigationDrawerSection>{children}</NavigationDrawerSection>
|
||||
</StyledFixedContainer>
|
||||
);
|
||||
|
||||
@@ -52,6 +52,10 @@ export type NavigationDrawerItemProps = {
|
||||
onClick?: () => void;
|
||||
Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element);
|
||||
iconColor?: string | null;
|
||||
// Wrap the plain icon in a soft grey tile (no border) — used by the
|
||||
// settings drawer so its icons read as a uniform group without picking
|
||||
// up TintedIconTile's bordered colored treatment.
|
||||
withIconBackground?: boolean;
|
||||
active?: boolean;
|
||||
modifier?: NavigationDrawerItemModifier;
|
||||
rightOptions?: ReactNode;
|
||||
@@ -202,6 +206,21 @@ const StyledIcon = styled.div`
|
||||
margin-right: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
// Soft grey background-only tile (no border) used by the settings drawer.
|
||||
// Sized one step larger than the icon so the icon sits with a couple of
|
||||
// pixels of breathing room on every side. radius.md matches the rest of
|
||||
// the App's small-card / tile language; radius.sm read as sharp squares.
|
||||
const StyledIconBackgroundTile = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.background.tertiary};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: ${themeCssVariables.spacing[6]};
|
||||
justify-content: center;
|
||||
width: ${themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
||||
const StyledRightOptionsContainer = styled.div`
|
||||
align-items: center;
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
@@ -243,6 +262,7 @@ export const NavigationDrawerItem = ({
|
||||
indentationLevel = DEFAULT_INDENTATION_LEVEL,
|
||||
Icon,
|
||||
iconColor,
|
||||
withIconBackground = false,
|
||||
to,
|
||||
onClick,
|
||||
active,
|
||||
@@ -347,6 +367,20 @@ export const NavigationDrawerItem = ({
|
||||
<StyledIcon>
|
||||
<TintedIconTile Icon={Icon} color={iconColor} />
|
||||
</StyledIcon>
|
||||
) : withIconBackground ? (
|
||||
<StyledIcon>
|
||||
<StyledIconBackgroundTile>
|
||||
<Icon
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.md}
|
||||
color={
|
||||
showBreadcrumb && !isExpanded
|
||||
? theme.font.color.light
|
||||
: 'currentColor'
|
||||
}
|
||||
/>
|
||||
</StyledIconBackgroundTile>
|
||||
</StyledIcon>
|
||||
) : (
|
||||
<StyledIcon>
|
||||
<Icon
|
||||
|
||||
@@ -11,11 +11,10 @@ const StyledItemsContainer = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledScrollableInnerContainer = styled.div<{ isMobile?: boolean }>`
|
||||
const StyledScrollableMobileInnerContainer = styled.div`
|
||||
height: 100%;
|
||||
padding-left: ${themeCssVariables.spacing[5]};
|
||||
padding-right: ${({ isMobile }) =>
|
||||
isMobile ? themeCssVariables.spacing[5] : themeCssVariables.spacing[8]};
|
||||
padding-right: ${themeCssVariables.spacing[5]};
|
||||
`;
|
||||
|
||||
export const NavigationDrawerScrollableContent = ({
|
||||
@@ -34,10 +33,10 @@ export const NavigationDrawerScrollableContent = ({
|
||||
defaultEnableXScroll={false}
|
||||
>
|
||||
<StyledItemsContainer>
|
||||
{isSettingsDrawer || isMobile ? (
|
||||
<StyledScrollableInnerContainer isMobile={isMobile}>
|
||||
{isMobile ? (
|
||||
<StyledScrollableMobileInnerContainer>
|
||||
{children}
|
||||
</StyledScrollableInnerContainer>
|
||||
</StyledScrollableMobileInnerContainer>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
|
||||
@@ -5,22 +5,19 @@ import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
const StyledSection = styled.div<{ isSettingsDrawer?: boolean }>`
|
||||
margin-bottom: ${({ isSettingsDrawer }) =>
|
||||
isSettingsDrawer ? themeCssVariables.spacing[3] : '0'};
|
||||
const StyledSection = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSectionInnerContainerMinusScrollPadding = styled.div<{
|
||||
isMobile: boolean;
|
||||
isSettingsDrawer: boolean;
|
||||
isMainNavCollapsed: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.betweenSiblingsGap};
|
||||
width: ${({ isMobile, isSettingsDrawer, isMainNavCollapsed }) =>
|
||||
isMobile || isSettingsDrawer || isMainNavCollapsed
|
||||
width: ${({ isMobile, isMainNavCollapsed }) =>
|
||||
isMobile || isMainNavCollapsed
|
||||
? '100%'
|
||||
: `calc(100% - ${themeCssVariables.spacing[2]})`};
|
||||
`;
|
||||
@@ -41,10 +38,9 @@ export const NavigationDrawerSection = ({
|
||||
!isSettingsDrawer && !isMobile && !isNavigationDrawerExpanded;
|
||||
|
||||
return (
|
||||
<StyledSection isSettingsDrawer={isSettingsDrawer} className={className}>
|
||||
<StyledSection className={className}>
|
||||
<StyledSectionInnerContainerMinusScrollPadding
|
||||
isMobile={isMobile}
|
||||
isSettingsDrawer={isSettingsDrawer}
|
||||
isMainNavCollapsed={isMainNavCollapsed}
|
||||
>
|
||||
{children}
|
||||
|
||||