@object-ui/app-shell 4.0.7 → 4.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.0.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 19c044f: i18n
8
+ - @object-ui/types@4.0.9
9
+ - @object-ui/core@4.0.9
10
+ - @object-ui/i18n@4.0.9
11
+ - @object-ui/react@4.0.9
12
+ - @object-ui/components@4.0.9
13
+ - @object-ui/fields@4.0.9
14
+ - @object-ui/layout@4.0.9
15
+ - @object-ui/data-objectstack@4.0.9
16
+ - @object-ui/auth@4.0.9
17
+ - @object-ui/permissions@4.0.9
18
+ - @object-ui/plugin-calendar@4.0.9
19
+ - @object-ui/plugin-charts@4.0.9
20
+ - @object-ui/plugin-chatbot@4.0.9
21
+ - @object-ui/plugin-dashboard@4.0.9
22
+ - @object-ui/plugin-designer@4.0.9
23
+ - @object-ui/plugin-detail@4.0.9
24
+ - @object-ui/plugin-form@4.0.9
25
+ - @object-ui/plugin-grid@4.0.9
26
+ - @object-ui/plugin-kanban@4.0.9
27
+ - @object-ui/plugin-list@4.0.9
28
+ - @object-ui/plugin-report@4.0.9
29
+ - @object-ui/plugin-view@4.0.9
30
+ - @object-ui/collaboration@4.0.9
31
+
32
+ ## 4.0.8
33
+
34
+ ### Patch Changes
35
+
36
+ - 3d58eaa: fix(auth,app-shell): hide Log out menu item when auth is disabled (guest/preview mode)
37
+
38
+ When the console runs against a server with `discovery.services.auth.enabled === false`
39
+ (or in preview mode), `AuthProvider` hardcodes `isAuthenticated: true` and the mock
40
+ `signOut()` has no real backend. Previously, clicking "Log out" in the user menu had
41
+ no visible effect — the user/session were nulled but the UI stayed authenticated.
42
+
43
+ Changes:
44
+ - **`@object-ui/auth`** — added `isAuthEnabled: boolean` to `AuthContextValue`
45
+ (`true` only when real auth is in use, `false` for guest/preview modes).
46
+ - **`@object-ui/app-shell`** — `AppHeader` and `AppSidebar` now hide the "Log out"
47
+ menu item entirely when `!isAuthEnabled`, so users aren't presented with an action
48
+ that can't actually do anything. Also fixed two missed i18n strings in
49
+ `AppSidebar` ("Settings", "Log out").
50
+ - **`@object-ui/i18n`** — added `user.{profile,settings,logout}` namespace to all
51
+ 10 built-in locales (en/zh translated; ja/ko/de/fr/es/pt/ru/ar fall back to
52
+ English pending native translation).
53
+
54
+ - Updated dependencies [3d58eaa]
55
+ - @object-ui/auth@4.0.8
56
+ - @object-ui/i18n@4.0.8
57
+ - @object-ui/components@4.0.8
58
+ - @object-ui/fields@4.0.8
59
+ - @object-ui/plugin-calendar@4.0.8
60
+ - @object-ui/plugin-charts@4.0.8
61
+ - @object-ui/plugin-dashboard@4.0.8
62
+ - @object-ui/plugin-designer@4.0.8
63
+ - @object-ui/plugin-list@4.0.8
64
+ - @object-ui/react@4.0.8
65
+ - @object-ui/layout@4.0.8
66
+ - @object-ui/plugin-chatbot@4.0.8
67
+ - @object-ui/plugin-detail@4.0.8
68
+ - @object-ui/plugin-form@4.0.8
69
+ - @object-ui/plugin-grid@4.0.8
70
+ - @object-ui/plugin-kanban@4.0.8
71
+ - @object-ui/plugin-report@4.0.8
72
+ - @object-ui/plugin-view@4.0.8
73
+ - @object-ui/types@4.0.8
74
+ - @object-ui/core@4.0.8
75
+ - @object-ui/data-objectstack@4.0.8
76
+ - @object-ui/permissions@4.0.8
77
+ - @object-ui/collaboration@4.0.8
78
+
3
79
  ## 4.0.7
4
80
 
5
81
  ### Patch Changes
@@ -264,7 +264,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
264
264
  const expressionUser = user
265
265
  ? { name: user.name, email: user.email, role: user.role ?? 'user' }
266
266
  : { name: 'Anonymous', email: '', role: 'guest' };
267
- return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: findFirstRoute(activeApp.navigation || []), replace: true }) }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }, refreshKey) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "design/page/:pageName", element: _jsx(PageDesignPage, {}) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), extraRoutes] }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
267
+ return (_jsxs(ExpressionProvider, { user: expressionUser, app: activeApp, data: {}, children: [_jsx(NavigationSyncEffect, {}), _jsxs(ConsoleLayout, { activeAppName: activeApp.name, activeApp: activeApp, onAppChange: handleAppChange, objects: allObjects, connectionState: connectionState, children: [_jsx(CommandPalette, { apps: apps, activeApp: activeApp, objects: allObjects, onAppChange: handleAppChange }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: findFirstRoute(activeApp.navigation || []), replace: true }) }), _jsx(Route, { path: ":objectName", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/new", element: _jsx(RecordFormPage, { mode: "create" }) }), _jsx(Route, { path: ":objectName/view/:viewId", element: _jsx(ObjectView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit, externalRefreshKey: refreshKey }) }), _jsx(Route, { path: ":objectName/record/:recordId", element: _jsx(RecordDetailView, { dataSource: dataSource, objects: allObjects, onEdit: handleEdit }, refreshKey) }), _jsx(Route, { path: ":objectName/record/:recordId/edit", element: _jsx(RecordFormPage, { mode: "edit" }) }), _jsx(Route, { path: "dashboard/:dashboardName", element: _jsx(DashboardView, { dataSource: dataSource }) }), _jsx(Route, { path: "report/:reportName", element: _jsx(ReportView, { dataSource: dataSource }) }), _jsx(Route, { path: "page/:pageName", element: _jsx(PageView, {}) }), _jsx(Route, { path: "design/page/:pageName", element: _jsx(PageDesignPage, {}) }), _jsx(Route, { path: "design/dashboard/:dashboardName", element: _jsx(DashboardDesignPage, {}) }), _jsx(Route, { path: "search", element: _jsx(SearchResultsPage, {}) }), _jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "edit-app/:editAppName", element: _jsx(EditAppPage, {}) }), extraRoutes] }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
268
268
  type: 'object-form',
269
269
  formType: 'modal',
270
270
  objectName: currentObjectDef.name,
@@ -296,7 +296,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
296
296
  onCancel: handleDialogCancel,
297
297
  showSubmit: true,
298
298
  showCancel: true,
299
- submitText: t('form.saveRecord'),
299
+ submitText: editingRecord
300
+ ? t('form.update', { defaultValue: t('common.save') || '保存' })
301
+ : t('form.create', { defaultValue: t('common.create') || '创建' }),
300
302
  cancelText: t('common.cancel'),
301
303
  }, dataSource: dataSource }, editingRecord?.id || 'new'))] })] }));
302
304
  }
@@ -41,11 +41,35 @@ export function useObjectActions({ objectName, objectLabel, dataSource, onEdit,
41
41
  // Accept several param shapes used across call sites:
42
42
  // { params: { recordId } } — toolbar / programmatic deletes
43
43
  // { params: { record } } — ObjectGrid row dropdown
44
- // { params: { records: [...] } } — bulk delete
44
+ // { params: { records: [...] } } — bulk delete (multi-row)
45
45
  // { recordId } (legacy) — pre-params shape
46
+ const records = Array.isArray(action.params?.records)
47
+ ? action.params.records.filter((r) => r?.id != null)
48
+ : null;
49
+ // Bulk path — delete every record in parallel and report a summary.
50
+ if (records && records.length > 1) {
51
+ const results = await Promise.allSettled(records.map((r) => dataSource.delete(objectName, r.id)));
52
+ const failed = results.filter(r => r.status === 'rejected').length;
53
+ const succeeded = results.length - failed;
54
+ onRefresh?.();
55
+ if (failed === 0) {
56
+ toast.success(t('objectActions.bulkDeleteSuccess', {
57
+ count: succeeded,
58
+ label: objectLabel || objectName,
59
+ defaultValue: `Deleted ${succeeded} ${objectLabel || objectName} records`,
60
+ }));
61
+ return { success: true, reload: true };
62
+ }
63
+ toast.error(t('objectActions.bulkDeletePartial', {
64
+ succeeded,
65
+ failed,
66
+ defaultValue: `${succeeded} deleted, ${failed} failed`,
67
+ }));
68
+ return { success: false, error: `${failed} failed` };
69
+ }
46
70
  const recordId = action.params?.recordId ??
47
71
  action.params?.record?.id ??
48
- action.params?.records?.[0]?.id ??
72
+ records?.[0]?.id ??
49
73
  action.recordId;
50
74
  if (!recordId)
51
75
  return { success: false, error: t('objectActions.noRecordId') };
@@ -50,10 +50,10 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
50
50
  const params = useParams();
51
51
  const navigate = useNavigate();
52
52
  const { isOnline } = useOffline();
53
- const { user, signOut, organizations, activeOrganization, isOrganizationsLoading, } = useAuth();
53
+ const { user, signOut, isAuthEnabled, organizations, activeOrganization, isOrganizationsLoading, } = useAuth();
54
54
  const dataSource = useAdapter();
55
55
  const { t } = useObjectTranslation();
56
- const { objectLabel, dashboardLabel, pageLabel, reportLabel } = useObjectLabel();
56
+ const { objectLabel, dashboardLabel, pageLabel, reportLabel, viewLabel } = useObjectLabel();
57
57
  const { apps: metadataApps, dashboards: metadataDashboards, pages: metadataPages, reports: metadataReports } = useMetadata();
58
58
  const { currentAppName, recordTitle } = useNavigationContext();
59
59
  const [apiPresenceUsers, setApiPresenceUsers] = useState(null);
@@ -66,10 +66,15 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
66
66
  const fetchPresenceAndActivities = useCallback(async () => {
67
67
  if (!dataSource || !isApp)
68
68
  return;
69
+ // ObjectStack client throws Error objects with `httpStatus` (not `status`)
70
+ // and a `code` like `object_not_found` when the underlying object isn't
71
+ // registered on the server. Either signal means the feature is
72
+ // unavailable — disable it for the rest of the page.
73
+ const isMissingResource = (err) => err?.httpStatus === 404 || err?.status === 404 || err?.code === 'object_not_found';
69
74
  const presenceP = presenceUnavailableRef.current
70
75
  ? Promise.resolve({ data: [] })
71
76
  : dataSource.find('sys_presence').catch((err) => {
72
- if (err?.status === 404)
77
+ if (isMissingResource(err))
73
78
  presenceUnavailableRef.current = true;
74
79
  return { data: [] };
75
80
  });
@@ -78,7 +83,7 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
78
83
  : dataSource
79
84
  .find('sys_activity', { $orderby: { timestamp: 'desc' }, $top: 20 })
80
85
  .catch((err) => {
81
- if (err?.status === 404)
86
+ if (isMissingResource(err))
82
87
  activityUnavailableRef.current = true;
83
88
  return { data: [] };
84
89
  });
@@ -187,15 +192,16 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
187
192
  const viewName = pathParts[4];
188
193
  const definedViews = currentObject.listViews || currentObject.list_views || {};
189
194
  const viewDef = definedViews[viewName];
190
- const viewLabel = (viewDef && (viewDef.label || viewDef.title)) || humanizeSlug(viewName);
191
- extraSegments.push({ label: viewLabel });
195
+ const fallbackLabel = (viewDef && (viewDef.label || viewDef.title)) || humanizeSlug(viewName);
196
+ const localizedViewLabel = viewLabel(currentObject.name, viewName, fallbackLabel);
197
+ extraSegments.push({ label: localizedViewLabel });
192
198
  }
193
199
  }
194
200
  }
195
201
  }
196
202
  const lastSegmentLabel = extraSegments[extraSegments.length - 1]?.label || appName || '';
197
- return (_jsxs("div", { className: "flex items-center justify-between w-full h-full", children: [_jsxs("div", { className: "flex items-center min-w-0 flex-1", children: [_jsx(Link, { to: "/home", className: "flex items-center justify-center h-7 w-7 shrink-0 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors", title: "ObjectStack", children: _jsx(Boxes, { className: "h-4 w-4" }) }), resolvedVariant === 'home' && (_jsx("span", { className: "hidden sm:inline ml-2 text-sm font-semibold tracking-tight", children: "ObjectStack" })), resolvedVariant === 'orgs' && (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: t('organizations.title', { defaultValue: 'Organizations' }) })] })), isApp && (_jsxs(_Fragment, { children: [_jsx(SidebarTrigger, { className: "md:hidden shrink-0 ml-1" }), activeAppName && onAppChange ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx(AppSwitcher, { activeAppName: activeAppName, onAppChange: onAppChange })] })) : appName ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: appName })] })) : null, extraSegments.map((seg, i) => {
203
+ return (_jsxs("div", { className: "flex items-center justify-between w-full h-full", children: [_jsxs("div", { className: "flex items-center min-w-0 flex-1", children: [_jsx(Link, { to: "/home", className: "flex items-center justify-center h-7 w-7 shrink-0 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors", title: "ObjectStack", children: _jsx(Boxes, { className: "h-4 w-4" }) }), resolvedVariant === 'home' && (_jsx("span", { className: "hidden sm:inline ml-2 text-sm font-semibold tracking-tight", children: "ObjectStack" })), resolvedVariant === 'orgs' && (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: t('organizations.title', { defaultValue: 'Organizations' }) })] })), isApp && (_jsxs(_Fragment, { children: [_jsx(SidebarTrigger, { className: "md:hidden shrink-0 ml-1", "aria-label": t('common.toggleSidebar') || 'Toggle sidebar' }), activeAppName && onAppChange ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx(AppSwitcher, { activeAppName: activeAppName, onAppChange: onAppChange })] })) : appName ? (_jsxs(_Fragment, { children: [_jsx(PathSep, {}), _jsx("span", { className: "text-sm font-medium text-foreground/80 px-1.5", children: appName })] })) : null, extraSegments.map((seg, i) => {
198
204
  const isLast = i === extraSegments.length - 1;
199
205
  return (_jsxs("span", { className: "hidden sm:flex items-center min-w-0", children: [_jsx(PathSep, {}), seg.siblings && seg.siblings.length > 1 ? (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { className: `flex items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium transition-colors outline-none hover:bg-accent hover:text-foreground ${!isLast ? 'text-foreground/60' : 'text-foreground/80'}`, children: [seg.label, _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }), _jsxs(DropdownMenuContent, { align: "start", sideOffset: 8, className: "w-56 max-h-72 overflow-y-auto", children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground font-normal", children: "Switch Object" }), _jsx(DropdownMenuSeparator, {}), seg.siblings.map((sibling) => (_jsx(DropdownMenuItem, { asChild: true, children: _jsx(Link, { to: sibling.href, className: "w-full", children: sibling.label }) }, sibling.href)))] })] })) : seg.href ? (_jsx(Link, { to: seg.href, className: `rounded-md px-1.5 py-1 text-sm font-medium transition-colors hover:bg-accent hover:text-foreground truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label })) : (_jsx("span", { className: `px-1.5 py-1 text-sm font-medium truncate max-w-[160px] ${isLast ? 'text-foreground/80' : 'text-foreground/60'}`, children: seg.label }))] }, i));
200
- }), _jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel })] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search...' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search...' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ActivityFeed, { activities: activeActivities }) }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ModeToggle, {}) }), _jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(LocaleSwitcher, {}) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.settings', { defaultValue: 'Settings' })] })] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] })] })] })] })] }));
206
+ }), _jsx("span", { className: "text-sm font-medium sm:hidden truncate min-w-0 ml-1", children: lastSegmentLabel })] }))] }), _jsxs("div", { className: "flex items-center gap-0.5 sm:gap-1 shrink-0 [&>*+*[data-topbar-group]]:ml-1 [&>[data-topbar-group]+[data-topbar-group]]:border-l [&>[data-topbar-group]+[data-topbar-group]]:border-border/60 [&>[data-topbar-group]+[data-topbar-group]]:pl-1 sm:[&>[data-topbar-group]+[data-topbar-group]]:pl-2 sm:[&>[data-topbar-group]+[data-topbar-group]]:ml-2", children: [!isOnline && (_jsxs("div", { className: "flex items-center gap-1 px-2 py-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 text-xs font-medium", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-yellow-500 animate-pulse" }), "Offline"] })), isApp && connectionState && _jsx(ConnectionStatus, { state: connectionState }), isApp && activeUsers.length > 0 && (_jsx("div", { className: "hidden md:flex items-center shrink-0", title: "Users currently online", children: _jsx(PresenceAvatars, { users: activeUsers, size: "sm", maxVisible: 3, showStatus: true }) })), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 sm:gap-1 shrink-0", children: [_jsxs("button", { onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), className: "hidden lg:flex relative items-center gap-2 w-48 xl:w-64 h-8 px-3 text-sm rounded-md border bg-muted/50 text-muted-foreground hover:bg-muted transition-colors", children: [_jsx(Search, { className: "h-3.5 w-3.5 shrink-0" }), _jsx("span", { className: "flex-1 text-left text-xs", children: t('console.search', { defaultValue: 'Search...' }) }), _jsxs("kbd", { className: "pointer-events-none inline-flex h-5 items-center gap-0.5 rounded border bg-background px-1.5 text-[10px] font-medium text-muted-foreground", children: [_jsx("span", { className: "text-xs", children: "\u2318" }), "K"] })] }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden h-8 w-8 shrink-0", onClick: () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })), "aria-label": t('console.search', { defaultValue: 'Search...' }), children: _jsx(Search, { className: "h-4 w-4" }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ActivityFeed, { activities: activeActivities }) }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 hidden md:flex shrink-0", asChild: true, "aria-label": t('sidebar.helpTooltip', { defaultValue: 'Help & Documentation' }), children: _jsx("a", { href: "https://docs.objectstack.ai", target: "_blank", rel: "noopener noreferrer", children: _jsx(HelpCircle, { className: "h-4 w-4" }) }) })] }), _jsxs("div", { "data-topbar-group": true, className: "flex items-center gap-0.5 shrink-0", children: [_jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(ModeToggle, {}) }), _jsx("div", { className: "hidden sm:flex shrink-0", children: _jsx(LocaleSwitcher, {}) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 shrink-0 rounded-full", children: _jsxs(Avatar, { className: "h-7 w-7 rounded-full", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-full bg-primary text-primary-foreground text-xs", children: getUserInitials(user) })] }) }) }), _jsxs(DropdownMenuContent, { align: "end", className: "min-w-64 rounded-lg", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-2 py-2", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [hasOrgSection && (_jsxs(DropdownMenuItem, { onClick: () => navigate('/organizations'), className: "cursor-pointer", children: [_jsx(Boxes, { className: "mr-2 h-4 w-4" }), t('organizations.mine', { defaultValue: 'My Organizations' })] })), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup/system/profile'), children: [_jsx(UserIcon, { className: "mr-2 h-4 w-4" }), t('user.profile', { defaultValue: 'Profile' })] }), _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('sidebar.settings', { defaultValue: 'Settings' })] })] }), isAuthEnabled && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] }))] })] })] })] })] }));
201
207
  }
@@ -91,7 +91,7 @@ function useNavOrder(appName) {
91
91
  const getIcon = resolveIcon;
92
92
  export function AppSidebar({ activeAppName, onAppChange }) {
93
93
  const { isMobile } = useSidebar();
94
- const { user, signOut } = useAuth();
94
+ const { user, signOut, isAuthEnabled } = useAuth();
95
95
  const navigate = useNavigate();
96
96
  const { t } = useObjectTranslation();
97
97
  const { objectLabel: resolveNavObjectLabel } = useObjectLabel();
@@ -184,7 +184,7 @@ export function AppSidebar({ activeAppName, onAppChange }) {
184
184
  }) }) })] })), _jsx(SidebarGroup, { className: "py-0", children: _jsxs(SidebarGroupContent, { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-70" }), _jsx(SidebarInput, { placeholder: "Search navigation...", value: navSearchQuery, onChange: (e) => setNavSearchQuery(e.target.value), className: "pl-8" })] }) }), _jsx(NavigationRenderer, { items: processedNavigation, basePath: basePath, evaluateVisibility: evalVis, checkPermission: checkPerm, searchQuery: navSearchQuery, enablePinning: true, onPinToggle: togglePin, enableReorder: true, onReorder: handleReorder, resolveObjectLabel: (objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback }), t: t }), recentItems.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5 cursor-pointer select-none", onClick: () => setRecentExpanded(prev => !prev), children: [_jsx(ChevronRight, { className: `h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}` }), _jsx(Clock, { className: "h-3.5 w-3.5" }), "Recent"] }), recentExpanded && (_jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: recentItems.slice(0, 5).map(item => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄' }), _jsx("span", { className: "truncate", children: item.label })] }) }) }, item.id))) }) }))] })), favorites.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), "Favorites"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": `Remove ${item.label} from favorites`, children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) : (_jsxs(SidebarGroup, { "data-testid": "system-fallback-nav", children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Settings, { className: "h-3.5 w-3.5" }), "System"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: systemFallbackNavigation.map((item) => {
185
185
  const NavIcon = getIcon(item.icon);
186
186
  return (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.url || '/system', children: [_jsx(NavIcon, { className: "h-4 w-4" }), _jsx("span", { children: item.label })] }) }) }, item.id));
187
- }) }) })] })) }), _jsx(SidebarFooter, { children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] }), _jsx(ChevronsUpDown, { className: "ml-auto size-4" })] }) }), _jsxs(DropdownMenuContent, { className: "w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg", side: isMobile ? "bottom" : "right", align: "end", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-1 py-1.5 text-left text-sm", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), "Settings"] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), "Log out"] })] })] }) }) }) })] }), isMobile && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: (activeApp ? resolvedNavigation : systemFallbackNavigation).filter((n) => n.type !== 'group').slice(0, 5).map((item) => {
187
+ }) }) })] })) }), _jsx(SidebarFooter, { children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] }), _jsx(ChevronsUpDown, { className: "ml-auto size-4" })] }) }), _jsxs(DropdownMenuContent, { className: "w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg", side: isMobile ? "bottom" : "right", align: "end", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "p-0 font-normal", children: _jsxs("div", { className: "flex items-center gap-2 px-1 py-1.5 text-left text-sm", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [_jsx(AvatarImage, { src: user?.image, alt: user?.name ?? 'User' }), _jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground", children: getUserInitials(user) })] }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: user?.name ?? 'User' }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: user?.email ?? '' })] })] }) }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuItem, { onClick: () => navigate('/apps/setup'), children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('user.settings', { defaultValue: 'Settings' })] }) }), isAuthEnabled && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "text-destructive focus:text-destructive", onClick: () => signOut(), children: [_jsx(LogOut, { className: "mr-2 h-4 w-4" }), t('user.logout', { defaultValue: 'Log out' })] })] }))] })] }) }) }) })] }), isMobile && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: (activeApp ? resolvedNavigation : systemFallbackNavigation).filter((n) => n.type !== 'group').slice(0, 5).map((item) => {
188
188
  const NavIcon = getIcon(item.icon);
189
189
  const baseUrl = activeApp ? `/apps/${activeAppName}` : '';
190
190
  let href = item.url || '#';
@@ -15,7 +15,7 @@ export function AppSwitcher({ activeAppName, onAppChange }) {
15
15
  if (!activeApp)
16
16
  return null;
17
17
  const appLabelText = appLabel({ name: activeApp.name, label: resolveI18nLabel(activeApp.label, t) });
18
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { className: "flex items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium text-foreground/80 hover:bg-accent hover:text-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring", children: [appLabelText, _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground" })] }) }), _jsxs(DropdownMenuContent, { align: "start", sideOffset: 8, className: "w-60", children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground font-normal", children: "Switch Application" }), _jsx(DropdownMenuSeparator, {}), activeApps.map((app) => {
18
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { className: "flex items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium text-foreground/80 hover:bg-accent hover:text-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring max-w-[40vw] sm:max-w-none", children: [_jsx("span", { className: "truncate whitespace-nowrap", children: appLabelText }), _jsx(ChevronDown, { className: "h-3.5 w-3.5 text-muted-foreground shrink-0" })] }) }), _jsxs(DropdownMenuContent, { align: "start", sideOffset: 8, className: "w-60", children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground font-normal", children: "Switch Application" }), _jsx(DropdownMenuSeparator, {}), activeApps.map((app) => {
19
19
  const AppIcon = getIcon(app.icon);
20
20
  const label = appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) });
21
21
  const isActive = activeApp.name === app.name;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * PageHeader Component
10
+ *
11
+ * Standardized page header used across console object/list/detail views.
12
+ * Provides consistent typography, icon framing, and action grouping so
13
+ * individual screens don't reinvent the title row.
14
+ *
15
+ * Layout:
16
+ * [icon] Title [actions...]
17
+ * description
18
+ */
19
+ import * as React from 'react';
20
+ export interface PageHeaderProps {
21
+ /** Page title (required, becomes <h1>). */
22
+ title: React.ReactNode;
23
+ /** Optional secondary description shown beneath the title. */
24
+ description?: React.ReactNode;
25
+ /** Optional leading icon (already-instantiated React element). */
26
+ icon?: React.ReactNode;
27
+ /** Right-aligned actions (buttons, dropdowns, etc.). */
28
+ actions?: React.ReactNode;
29
+ /** Additional className applied to the outer container. */
30
+ className?: string;
31
+ /** Optional data-testid for E2E selectors. */
32
+ 'data-testid'?: string;
33
+ }
34
+ /**
35
+ * Reusable page header for the console.
36
+ *
37
+ * Mirrors Shadcn / Linear / Notion conventions: subtle border, comfortable
38
+ * vertical padding, primary-tinted icon chip, monolithic action cluster on
39
+ * the right with consistent gap.
40
+ */
41
+ export declare function PageHeader({ title, description, icon, actions, className, 'data-testid': testId, }: PageHeaderProps): import("react/jsx-runtime").JSX.Element;
42
+ export default PageHeader;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from '@object-ui/components';
3
+ /**
4
+ * Reusable page header for the console.
5
+ *
6
+ * Mirrors Shadcn / Linear / Notion conventions: subtle border, comfortable
7
+ * vertical padding, primary-tinted icon chip, monolithic action cluster on
8
+ * the right with consistent gap.
9
+ */
10
+ export function PageHeader({ title, description, icon, actions, className, 'data-testid': testId, }) {
11
+ return (_jsxs("div", { className: cn('flex justify-between items-center gap-3 py-1.5 sm:py-2 px-3 sm:px-4 border-b shrink-0 bg-background z-10', className), "data-testid": testId ?? 'page-header', children: [_jsxs("div", { className: "flex items-center gap-2 sm:gap-2.5 min-w-0 flex-1", children: [icon && (_jsx("div", { className: "bg-primary/10 p-1 sm:p-1.5 rounded-md shrink-0 text-primary", children: icon })), _jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "text-sm sm:text-base font-semibold tracking-tight text-foreground truncate", children: title }), description && (_jsx("p", { className: "text-xs text-muted-foreground truncate hidden sm:block max-w-md", children: description }))] })] }), actions && (_jsx("div", { className: "flex items-center gap-1.5 sm:gap-2 shrink-0", children: actions }))] }));
12
+ }
13
+ export default PageHeader;
@@ -9,3 +9,5 @@ export type { ActivityItem } from './ActivityFeed';
9
9
  export { LocaleSwitcher } from './LocaleSwitcher';
10
10
  export { ModeToggle } from './ModeToggle';
11
11
  export { AuthPageLayout } from './AuthPageLayout';
12
+ export { PageHeader } from './PageHeader';
13
+ export type { PageHeaderProps } from './PageHeader';
@@ -8,3 +8,4 @@ export { ActivityFeed } from './ActivityFeed';
8
8
  export { LocaleSwitcher } from './LocaleSwitcher';
9
9
  export { ModeToggle } from './ModeToggle';
10
10
  export { AuthPageLayout } from './AuthPageLayout';
11
+ export { PageHeader } from './PageHeader';
@@ -16,7 +16,10 @@ import React from 'react';
16
16
  *
17
17
  * Returns a React component that lazy-loads the underlying SVG icon on
18
18
  * mount. Falls back to the `Database` icon (statically imported) when no
19
- * `name` is given.
19
+ * `name` is given, or when the requested name is not a valid Lucide icon
20
+ * — server-driven metadata frequently references icons from other libraries
21
+ * (e.g. `box-open` from Font Awesome), and we silently degrade to the
22
+ * fallback rather than letting Lucide log a console error.
20
23
  *
21
24
  * The returned component is memoised per `name` so repeated calls with the
22
25
  * same name yield the same component reference (stable for React.memo).
@@ -13,7 +13,7 @@
13
13
  import React from 'react';
14
14
  import { Database } from 'lucide-react';
15
15
  // @ts-ignore - lucide-react has no `exports` field; subpath types live alongside dynamic.mjs
16
- import { DynamicIcon } from 'lucide-react/dynamic.mjs';
16
+ import { DynamicIcon, iconNames } from 'lucide-react/dynamic.mjs';
17
17
  /** Convert PascalCase / camelCase / mixed names to kebab-case for DynamicIcon. */
18
18
  function toKebab(name) {
19
19
  if (name.includes('-'))
@@ -23,13 +23,18 @@ function toKebab(name) {
23
23
  .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
24
24
  .toLowerCase();
25
25
  }
26
+ // Lucide ships ~3900 icon names; storing as a Set keeps lookups O(1).
27
+ const VALID_ICON_NAMES = new Set(iconNames);
26
28
  const cache = new Map();
27
29
  /**
28
30
  * Resolve a Lucide icon by name (kebab-case or PascalCase).
29
31
  *
30
32
  * Returns a React component that lazy-loads the underlying SVG icon on
31
33
  * mount. Falls back to the `Database` icon (statically imported) when no
32
- * `name` is given.
34
+ * `name` is given, or when the requested name is not a valid Lucide icon
35
+ * — server-driven metadata frequently references icons from other libraries
36
+ * (e.g. `box-open` from Font Awesome), and we silently degrade to the
37
+ * fallback rather than letting Lucide log a console error.
33
38
  *
34
39
  * The returned component is memoised per `name` so repeated calls with the
35
40
  * same name yield the same component reference (stable for React.memo).
@@ -41,6 +46,10 @@ export function getIcon(name) {
41
46
  if (cached)
42
47
  return cached;
43
48
  const kebab = toKebab(name);
49
+ if (!VALID_ICON_NAMES.has(kebab)) {
50
+ cache.set(name, Database);
51
+ return Database;
52
+ }
44
53
  const Wrapped = (props) => React.createElement(DynamicIcon, {
45
54
  name: kebab,
46
55
  fallback: Database,