shutro-erp 0.3.3 → 0.3.4

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.
Files changed (85) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +128 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +128 -1
  5. package/dist/contacts/.kosh/bindings.js +1 -1
  6. package/dist/contacts/.kosh/schema.d.ts +128 -1
  7. package/dist/integrations/index.js +12 -0
  8. package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
  9. package/dist/integrations/web/OAuthAppsPage.js +79 -0
  10. package/dist/integrations/web/index.d.ts +1 -0
  11. package/dist/integrations/web/index.js +3 -0
  12. package/dist/integrations/web/messages.js +58 -0
  13. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  14. package/dist/l10n_bd/.kosh/schema.d.ts +128 -1
  15. package/dist/l10n_in/.kosh/bindings.js +1 -1
  16. package/dist/l10n_in/.kosh/schema.d.ts +128 -1
  17. package/dist/messaging/web/index.js +1 -1
  18. package/dist/notify/web/index.js +1 -1
  19. package/dist/payment/.kosh/bindings.js +1 -1
  20. package/dist/payment/.kosh/schema.d.ts +128 -1
  21. package/dist/portal/.kosh/bindings.js +1 -1
  22. package/dist/portal/.kosh/schema.d.ts +128 -1
  23. package/dist/product/.kosh/bindings.js +1 -1
  24. package/dist/product/.kosh/schema.d.ts +128 -1
  25. package/dist/project/.kosh/bindings.js +1 -1
  26. package/dist/project/.kosh/schema.d.ts +128 -1
  27. package/dist/project_portal/.kosh/bindings.js +1 -1
  28. package/dist/project_portal/.kosh/schema.d.ts +128 -1
  29. package/dist/purchase/.kosh/bindings.js +1 -1
  30. package/dist/purchase/.kosh/schema.d.ts +128 -1
  31. package/dist/purchase_account/.kosh/bindings.js +1 -1
  32. package/dist/purchase_account/.kosh/schema.d.ts +128 -1
  33. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  34. package/dist/purchase_portal/.kosh/schema.d.ts +128 -1
  35. package/dist/sale_account/.kosh/bindings.js +1 -1
  36. package/dist/sale_account/.kosh/schema.d.ts +128 -1
  37. package/dist/sale_portal/.kosh/bindings.js +1 -1
  38. package/dist/sale_portal/.kosh/schema.d.ts +128 -1
  39. package/dist/sale_project/.kosh/bindings.js +1 -1
  40. package/dist/sale_project/.kosh/schema.d.ts +128 -1
  41. package/dist/sales/.kosh/bindings.js +1 -1
  42. package/dist/sales/.kosh/schema.d.ts +128 -1
  43. package/dist/server/auth/base.js +76 -0
  44. package/dist/server/auth/oauth.d.ts +21 -0
  45. package/dist/server/auth/oauth.js +579 -0
  46. package/dist/server/auth/service.d.ts +4 -0
  47. package/dist/server/auth/service.js +17 -0
  48. package/dist/server/identity.d.ts +2 -0
  49. package/dist/server/images.d.ts +12 -0
  50. package/dist/server/images.js +139 -0
  51. package/dist/server/index.d.ts +1 -0
  52. package/dist/server/index.js +1 -0
  53. package/dist/server/server.d.ts +4 -0
  54. package/dist/server/server.js +9 -1
  55. package/dist/settings/company.js +3 -1
  56. package/dist/settings/people.js +3 -1
  57. package/dist/settings/web/CompaniesPage.js +2 -2
  58. package/dist/settings/web/CompanyPage.js +13 -2
  59. package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
  60. package/dist/settings/web/ConnectedAppsPage.js +33 -0
  61. package/dist/settings/web/OverviewPage.js +12 -1
  62. package/dist/settings/web/PeoplePage.js +3 -12
  63. package/dist/settings/web/ProfilePage.js +9 -7
  64. package/dist/settings/web/index.d.ts +1 -0
  65. package/dist/settings/web/index.js +5 -0
  66. package/dist/settings/web/messages.js +28 -0
  67. package/dist/ui/api.d.ts +4 -0
  68. package/dist/ui/app.js +49 -16
  69. package/dist/ui/components/AppIcon.js +11 -1
  70. package/dist/ui/components/Avatar.d.ts +21 -0
  71. package/dist/ui/components/Avatar.js +71 -0
  72. package/dist/ui/i18n/messages.d.ts +30 -0
  73. package/dist/ui/i18n/messages.js +60 -0
  74. package/dist/ui/index.d.ts +1 -0
  75. package/dist/ui/index.js +1 -0
  76. package/dist/ui/registry.d.ts +7 -1
  77. package/dist/ui/registry.js +10 -1
  78. package/dist/ui/search/ViewTabs.d.ts +14 -0
  79. package/dist/ui/search/ViewTabs.js +42 -0
  80. package/dist/ui/shell/Consent.d.ts +5 -0
  81. package/dist/ui/shell/Consent.js +35 -0
  82. package/dist/ui/shell/Home.js +85 -16
  83. package/dist/ui/shell/Sidebar.js +15 -22
  84. package/dist/ui/views/ListView.js +49 -22
  85. package/package.json +1 -1
@@ -1,6 +1,17 @@
1
1
  /** Messages of the Settings app (namespace `settings`). */
2
2
  export const messages = {
3
3
  en: {
4
+ connected: {
5
+ title: 'Connected apps',
6
+ intro: 'Apps you allowed to act for you. They can never do more than you may; remove one and its access ends at once.',
7
+ none: 'You have not allowed any app.',
8
+ since: 'allowed {{when}}',
9
+ lastUsed: 'last used {{when}}',
10
+ neverUsed: 'not used yet',
11
+ revoke: 'Remove access',
12
+ revokeApp: 'Remove the access of {{app}}',
13
+ homepage: 'About {{app}}',
14
+ },
4
15
  roles: {
5
16
  title: 'Roles & permissions',
6
17
  intro: 'What each role lets people open and do. Changes are previewed before they are saved.',
@@ -133,6 +144,10 @@ export const messages = {
133
144
  description: 'Every company, its currency and people; switch between them or add one.',
134
145
  keywords: 'companies multi-company branches new company switch',
135
146
  },
147
+ 'settings.action_people': {
148
+ description: 'Invite people, give them roles per app and company, deactivate them, reset their two-factor sign-in.',
149
+ keywords: 'people users employees invite invitation roles deactivate 2fa reset',
150
+ },
136
151
  'settings.action_roles': {
137
152
  description: 'What each role opens and allows, rights per model, record rules; previewed before saving.',
138
153
  keywords: 'roles permissions access rights groups record rules field security acl',
@@ -184,6 +199,7 @@ export const messages = {
184
199
  turnOff: 'Turn off two-factor sign-in',
185
200
  password: 'Password',
186
201
  recoveryCodes: 'Your recovery codes: each signs you in once if you lose your phone. Keep them somewhere safe; they are shown only now.',
202
+ photo: 'Photo',
187
203
  sessions: 'Signed-in devices',
188
204
  sessionsHint: 'Where you are signed in now. Sign out a device you do not recognise.',
189
205
  thisDevice: 'This device',
@@ -368,6 +384,17 @@ export const messages = {
368
384
  version: 'Version {{version}}',
369
385
  },
370
386
  bn: {
387
+ connected: {
388
+ title: 'সংযুক্ত অ্যাপস',
389
+ intro: 'যে অ্যাপগুলোকে আপনি আপনার হয়ে কাজ করতে দিয়েছেন। সরিয়ে দিলে সাথে সাথে প্রবেশাধিকার শেষ।',
390
+ none: 'আপনি কোনো অ্যাপকে অনুমতি দেননি।',
391
+ since: '{{when}} থেকে অনুমতি',
392
+ lastUsed: 'সর্বশেষ ব্যবহার {{when}}',
393
+ neverUsed: 'এখনো ব্যবহার হয়নি',
394
+ revoke: 'প্রবেশাধিকার সরান',
395
+ revokeApp: '{{app}}-এর প্রবেশাধিকার সরান',
396
+ homepage: '{{app}} সম্পর্কে',
397
+ },
371
398
  roles: {
372
399
  title: 'রোল ও অনুমতি',
373
400
  intro: 'প্রতিটি রোল মানুষকে কী খুলতে ও করতে দেয়। সংরক্ষণের আগে পরিবর্তন দেখানো হয়।',
@@ -555,6 +582,7 @@ export const messages = {
555
582
  turnOff: 'দুই-ধাপের সাইন ইন বন্ধ করুন',
556
583
  password: 'পাসওয়ার্ড',
557
584
  recoveryCodes: 'আপনার রিকভারি কোড: ফোন হারালে প্রতিটি একবার সাইন ইন করায়। নিরাপদে রাখুন; শুধু এখনই দেখানো হচ্ছে।',
585
+ photo: 'ছবি',
558
586
  sessions: 'সাইন ইন করা ডিভাইস',
559
587
  sessionsHint: 'এখন যেখানে আপনি সাইন ইন আছেন। অচেনা ডিভাইস সাইন আউট করুন।',
560
588
  thisDevice: 'এই ডিভাইস',
package/dist/ui/api.d.ts CHANGED
@@ -74,6 +74,10 @@ export interface CurrentUser {
74
74
  readonly administratorNeedsSecondFactor: boolean;
75
75
  /** The policy requires two-factor sign-in of everyone and it is not on (RFC 0019 §3.5); older servers send none. */
76
76
  readonly twoFactorSetupRequired?: boolean;
77
+ /** The user's photo version, or null (older servers send none). */
78
+ readonly avatar?: string | null;
79
+ /** Each company's logo version, when it has one. */
80
+ readonly logos?: Readonly<Record<string, string>>;
77
81
  }
78
82
  export interface AppEntry {
79
83
  readonly code: string;
package/dist/ui/app.js CHANGED
@@ -12,11 +12,12 @@ import { compileSearch, decodeSearch, encodeSearch } from '../search/index.js';
12
12
  import { onlineManager, QueryClient, QueryClientProvider, useQuery, useQueryClient, } from '@tanstack/react-query';
13
13
  import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
14
14
  import { createRootRoute, createRoute, createRouter, Link, Navigate, Outlet, RouterProvider, useNavigate, useParams, useSearch, } from '@tanstack/react-router';
15
- import { CalendarDays, ChartColumn, ChartGantt, ChevronRight, ChevronsUpDown, Columns3, Grid2x2, Languages, LayoutDashboard, List, LogOut, Map as MapIcon, Monitor, Moon, Network, PanelLeft, Plus, Search, Sheet, Sun, Table2, } from 'lucide-react';
15
+ import { CalendarDays, ChartColumn, ChartGantt, ChevronDown, ChevronRight, ChevronsUpDown, Columns3, Grid2x2, House, Languages, LayoutDashboard, List, LogOut, Map as MapIcon, Monitor, Moon, Network, PanelLeft, Plus, Search, Sheet, Sun, Table2, } from 'lucide-react';
16
16
  import { Direction } from 'radix-ui';
17
17
  import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
18
18
  import { I18nextProvider, useTranslation } from 'react-i18next';
19
19
  import { ApiError } from './api.js';
20
+ import { Avatar, imageUrl } from './components/Avatar.js';
20
21
  import { Alert, Button, Menu, MenuCheckboxItem, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger, Spinner, } from './components/primitives.js';
21
22
  import { KoshProvider, useKosh } from './context.js';
22
23
  import { DesignProvider } from './design.js';
@@ -29,8 +30,10 @@ import { setPrefill, takePrefill } from './prefill.js';
29
30
  import { onAppUpdate, registerServiceWorker } from './pwa/register.js';
30
31
  import { createRealtimeClient } from './realtime.js';
31
32
  import { SearchBar } from './search/SearchBar.js';
33
+ import { ViewTabs } from './search/ViewTabs.js';
32
34
  import { Activate } from './shell/Activate.js';
33
35
  import { CommandPalette, PaletteContext } from './shell/CommandPalette.js';
36
+ import { Consent } from './shell/Consent.js';
34
37
  import { Home } from './shell/Home.js';
35
38
  import { Login } from './shell/Login.js';
36
39
  import { OfflineMenu } from './shell/OfflineMenu.js';
@@ -142,7 +145,8 @@ function CompanyBlock({ me, company, collapsed, onChange, }) {
142
145
  enabled: me.companies.length > 0,
143
146
  });
144
147
  const name = names.data?.find((c) => c.id === company)?.displayName ?? t('app.name');
145
- const badge = (_jsx("span", { "aria-hidden": true, className: "grid size-7 shrink-0 place-items-center rounded-md bg-primary text-xs font-semibold text-primary-foreground", children: name.slice(0, 1).toUpperCase() }));
148
+ // The company's logo, else its initial.
149
+ const badge = (_jsx(Avatar, { name: name.slice(0, 1), src: imageUrl('company', company, me.logos?.[String(company)]), shape: "square", className: "font-semibold" }));
146
150
  if (collapsed)
147
151
  return _jsx("span", { title: name, children: badge });
148
152
  if (me.companies.length < 2)
@@ -153,19 +157,16 @@ function CompanyBlock({ me, company, collapsed, onChange, }) {
153
157
  onChange(c.id);
154
158
  }, children: _jsxs("span", { className: "flex items-center gap-2", children: [_jsx("span", { "aria-hidden": true, className: "grid size-5 place-items-center rounded bg-muted text-[10px] font-semibold", children: c.displayName.slice(0, 1).toUpperCase() }), c.displayName] }) }, c.id)))] })] }));
155
159
  }
156
- /** The user at the bottom of the sidebar: their pages and signing out. */
157
- function UserMenu({ user, collapsed, }) {
160
+ function UserMenu({ user, collapsed, corner, }) {
158
161
  const { t } = useTranslation();
159
- const { api, registry, persister } = useServices();
162
+ const { api, registry, persister, lang, setLang } = useServices();
163
+ const { theme, setTheme, density, setDensity } = useKosh();
160
164
  const queryClient = useQueryClient();
161
165
  const navigate = useNavigate();
162
- const initials = user.name
163
- .split(/\s+/)
164
- .map((w) => w.slice(0, 1))
165
- .join('')
166
- .slice(0, 2)
167
- .toUpperCase();
168
- return (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": user.name, className: cn('flex w-full min-w-0 items-center gap-2 rounded-md px-1.5 py-1.5 text-sm hover:bg-muted', collapsed && 'justify-center'), children: [_jsx("span", { "aria-hidden": true, className: "grid size-7 shrink-0 place-items-center rounded-full bg-muted text-xs font-medium", children: initials }), collapsed ? null : _jsx("span", { className: "truncate", children: user.name })] }) }), _jsxs(MenuContent, { align: "start", side: "top", children: [_jsx(MenuLabel, { children: user.name }), registry.userPages().map((p) => (_jsx(MenuItem, { onSelect: () => void navigate({ to: '/me/$page', params: { page: p.name } }), children: t(p.label) }, p.name))), _jsxs(MenuItem, { onSelect: async () => {
166
+ return (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": user.name, "data-kosh-account": corner === undefined ? 'sidebar' : 'corner', className: cn('flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1.5 text-sm hover:bg-muted', corner === undefined ? 'w-full' : 'rounded-full border bg-surface pe-3', collapsed && 'justify-center'), children: [_jsx(Avatar, { name: user.name, src: imageUrl('user', user.id, user.avatar) }), collapsed ? null : _jsx("span", { className: "truncate", children: user.name }), corner === undefined ? null : (_jsx(ChevronDown, { className: "size-3.5 text-muted-foreground", "aria-hidden": true }))] }) }), _jsxs(MenuContent, { align: corner === undefined ? 'start' : 'end', side: corner === undefined ? 'top' : 'bottom', className: corner === undefined ? undefined : 'max-h-[80vh] w-64 overflow-y-auto', children: [_jsxs(MenuLabel, { children: [user.name, _jsx("span", { className: "block text-xs font-normal text-muted-foreground", children: user.login })] }), registry.userPages().map((p) => (_jsx(MenuItem, { onSelect: () => void navigate({ to: '/me/$page', params: { page: p.name } }), children: t(p.label) }, p.name))), corner === undefined ? null : (_jsxs(_Fragment, { children: [corner.companies.length < 2 ? null : (_jsxs(_Fragment, { children: [_jsx(MenuSeparator, {}), _jsx(MenuLabel, { children: t('shell.company') }), corner.companies.map((c) => (_jsx(MenuCheckboxItem, { checked: c.id === corner.current, onCheckedChange: () => {
167
+ if (c.id !== corner.current)
168
+ corner.onSwitch(c.id);
169
+ }, children: c.name }, c.id)))] })), _jsx(MenuSeparator, {}), _jsx(MenuLabel, { children: t('shell.language') }), LANGUAGES.map((l) => (_jsx(MenuCheckboxItem, { checked: lang === l.code, onCheckedChange: () => setLang(l.code), children: _jsx("span", { lang: l.code.replace('_', '-'), children: l.name }) }, l.code))), _jsx(MenuSeparator, {}), _jsx(MenuLabel, { children: t('shell.theme') }), ['light', 'dark', 'system'].map((choice) => (_jsx(MenuCheckboxItem, { checked: theme === choice, onCheckedChange: () => setTheme(choice), children: t(`shell.${choice}`) }, choice))), _jsx(MenuLabel, { children: t('shell.density') }), ['comfortable', 'compact'].map((choice) => (_jsx(MenuCheckboxItem, { checked: density === choice, onCheckedChange: () => setDensity(choice), children: t(`views.${choice}`) }, choice)))] })), _jsx(MenuSeparator, {}), _jsxs(MenuItem, { onSelect: async () => {
169
170
  await api.logout();
170
171
  queryClient.clear();
171
172
  await dropOfflineCache(persister);
@@ -247,11 +248,21 @@ function Shell() {
247
248
  setDrawer(false);
248
249
  setPalette(true);
249
250
  }, top: _jsx(CompanyBlock, { me: user, company: current, collapsed: inDrawer ? false : collapsed, onChange: switchCompany }), bottom: _jsx(UserMenu, { user: user, collapsed: inDrawer ? false : collapsed }), onNavigate: inDrawer ? () => setDrawer(false) : undefined }));
250
- return (_jsx(KoshProvider, { api: api, registry: registry, uid: user.id, companyId: current, lang: lang, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, grouping: user.groupings?.[String(current)] ?? 'international', accent: user.accents?.[String(current)] ?? null, ...(realtime === undefined ? {} : { realtime }), ...(offline === undefined ? {} : { offline }), switchCompany: switchCompany, children: _jsx(ToastProvider, { children: _jsxs(DesignProvider, { children: [_jsx(OfflineAgent, { uid: user.id }), _jsxs(PaletteContext.Provider, { value: openPalette, children: [_jsx("a", { href: "#content", className: "sr-only focus:not-sr-only focus:absolute focus:start-2 focus:top-2 focus:z-50 focus:rounded focus:bg-surface focus:px-3 focus:py-2", children: t('shell.skip') }), _jsxs("div", { className: "flex min-h-screen", children: [_jsx("aside", { "aria-label": t('shell.sidebar'), className: cn('sticky top-0 hidden h-screen shrink-0 border-e bg-surface transition-[width] lg:block', collapsed ? 'w-14' : 'w-60'), children: sidebar(false) }), _jsx(SidebarDrawer, { open: drawer, onOpenChange: setDrawer, children: sidebar(true) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("header", { className: "sticky top-0 z-30 border-b bg-surface", children: _jsxs("div", { className: "flex h-12 items-center gap-2 px-3 lg:px-4", children: [_jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden", "aria-label": t('shell.openMenu'), onClick: () => setDrawer(true), children: _jsx(PanelLeft, { className: "rtl:rotate-180", "aria-hidden": true }) }), _jsx("nav", { "aria-label": t('shell.location'), className: "flex min-w-0 flex-1 items-center gap-1.5 text-sm", children: app === undefined ? (_jsx("span", { className: "font-semibold", children: userPage !== undefined
251
+ return (_jsx(KoshProvider, { api: api, registry: registry, uid: user.id, companyId: current, lang: lang, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, grouping: user.groupings?.[String(current)] ?? 'international', accent: user.accents?.[String(current)] ?? null, ...(realtime === undefined ? {} : { realtime }), ...(offline === undefined ? {} : { offline }), switchCompany: switchCompany, children: _jsx(ToastProvider, { children: _jsxs(DesignProvider, { children: [_jsx(OfflineAgent, { uid: user.id }), _jsxs(PaletteContext.Provider, { value: openPalette, children: [_jsx("a", { href: "#content", className: "sr-only focus:not-sr-only focus:absolute focus:start-2 focus:top-2 focus:z-50 focus:rounded focus:bg-surface focus:px-3 focus:py-2", children: t('shell.skip') }), params.app === undefined ? (_jsxs("div", { className: "flex min-h-screen flex-col", children: [_jsxs("div", { className: "flex h-14 items-center gap-2 px-4 lg:px-6", children: [userPage === undefined ? null : (_jsxs(Link, { to: "/", className: "flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted hover:text-foreground", children: [_jsx(House, { className: "size-4", "aria-hidden": true }), t('shell.home')] })), _jsx("span", { className: "flex-1" }), registry
252
+ .headerItems()
253
+ .filter((item) => item.home)
254
+ .map(({ name, component: Item }) => (_jsx(Item, { api: api }, name))), _jsx(UserMenu, { user: user, collapsed: false, corner: {
255
+ companies: (companyNames.data ?? []).map((c) => ({
256
+ id: c.id,
257
+ name: c.displayName,
258
+ })),
259
+ current,
260
+ onSwitch: switchCompany,
261
+ } })] }), user.administratorNeedsSecondFactor ? (_jsx("div", { className: "px-4 pt-3", children: _jsx(Alert, { tone: "info", children: t('auth.adminNeeds2fa') }) })) : null, _jsx("main", { id: "content", className: "mx-auto w-full max-w-[100rem] flex-1 p-4 lg:p-6", children: _jsx(Outlet, {}) })] })) : (_jsxs("div", { className: "flex min-h-screen", children: [_jsx("aside", { "aria-label": t('shell.sidebar'), className: cn('sticky top-0 hidden h-screen shrink-0 border-e bg-surface transition-[width] lg:block', collapsed ? 'w-14' : 'w-60'), children: sidebar(false) }), _jsx(SidebarDrawer, { open: drawer, onOpenChange: setDrawer, children: sidebar(true) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("header", { className: "sticky top-0 z-30 border-b bg-surface", children: _jsxs("div", { className: "flex h-12 items-center gap-2 px-3 lg:px-4", children: [_jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden", "aria-label": t('shell.openMenu'), onClick: () => setDrawer(true), children: _jsx(PanelLeft, { className: "rtl:rotate-180", "aria-hidden": true }) }), _jsx("nav", { "aria-label": t('shell.location'), className: "flex min-w-0 flex-1 items-center gap-1.5 text-sm", children: app === undefined ? (_jsx("span", { className: "font-semibold", children: userPage !== undefined
251
262
  ? t(userPage.label)
252
263
  : params.app === undefined
253
264
  ? t('shell.home')
254
- : t('app.name') })) : (_jsxs(_Fragment, { children: [_jsx(Link, { to: "/$app", params: { app: app.code }, className: "font-semibold hover:underline", children: app.label }), section === undefined ? null : (_jsxs(_Fragment, { children: [_jsx(ChevronRight, { className: "size-3.5 text-muted-foreground rtl:rotate-180", "aria-hidden": true }), _jsx(Link, { to: "/$app/a/$action", params: { app: app.code, action: params.action ?? '' }, className: "truncate text-muted-foreground hover:text-foreground hover:underline", children: section })] }))] })) }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden", "aria-label": t('palette.button'), onClick: () => setPalette(true), children: _jsx(Search, { "aria-hidden": true }) }), _jsx(OfflineMenu, {}), registry.headerItems().map(({ name, component: Item }) => (_jsx(Item, { api: api }, name))), _jsx(LanguageMenu, {}), _jsx(ThemeMenu, {})] }) }), user.administratorNeedsSecondFactor ? (_jsx("div", { className: "px-4 pt-3", children: _jsx(Alert, { tone: "info", children: t('auth.adminNeeds2fa') }) })) : null, _jsx("main", { id: "content", className: "mx-auto w-full max-w-[100rem] flex-1 p-4 lg:p-6", children: _jsx(Outlet, {}) })] })] }), _jsx(CommandPalette, { open: palette, onOpenChange: setPalette, api: api, onShortcuts: openHelp, companies: (companyNames.data ?? [])
265
+ : t('app.name') })) : (_jsxs(_Fragment, { children: [_jsx(Link, { to: "/$app", params: { app: app.code }, className: "font-semibold hover:underline", children: app.label }), section === undefined ? null : (_jsxs(_Fragment, { children: [_jsx(ChevronRight, { className: "size-3.5 text-muted-foreground rtl:rotate-180", "aria-hidden": true }), _jsx(Link, { to: "/$app/a/$action", params: { app: app.code, action: params.action ?? '' }, className: "truncate text-muted-foreground hover:text-foreground hover:underline", children: section })] }))] })) }), _jsx(Button, { variant: "ghost", size: "icon", className: "lg:hidden", "aria-label": t('palette.button'), onClick: () => setPalette(true), children: _jsx(Search, { "aria-hidden": true }) }), _jsx(OfflineMenu, {}), registry.headerItems().map(({ name, component: Item }) => (_jsx(Item, { api: api }, name))), _jsx(LanguageMenu, {}), _jsx(ThemeMenu, {})] }) }), user.administratorNeedsSecondFactor ? (_jsx("div", { className: "px-4 pt-3", children: _jsx(Alert, { tone: "info", children: t('auth.adminNeeds2fa') }) })) : null, _jsx("main", { id: "content", className: "mx-auto w-full max-w-[100rem] flex-1 p-4 lg:p-6", children: _jsx(Outlet, {}) })] })] })), _jsx(CommandPalette, { open: palette, onOpenChange: setPalette, api: api, onShortcuts: openHelp, companies: (companyNames.data ?? [])
255
266
  .filter((c) => c.id !== current)
256
267
  .map((c) => ({ id: c.id, name: c.displayName })), onSwitchCompany: switchCompany }), _jsx(ShortcutsHelp, { open: help, onOpenChange: setHelp })] })] }) }) }));
257
268
  }
@@ -390,7 +401,7 @@ function WindowActionPage() {
390
401
  params: { app, action: actionId },
391
402
  search: { ...search, view: v },
392
403
  }), children: _jsx(Icon, { "aria-hidden": true }) }, v));
393
- })] })) : null] }), _jsx(SearchBar, { model: model, viewType: type, spec: spec.data, state: state, onChange: setState, labels: labels }), Renderer === undefined ? (_jsx(Alert, { children: `No renderer for '${served.data.view.component ?? type}'.` })) : (_jsx(Renderer, { served: served.data, where: compiled.where, groupBy: compiled.groupBy, order: compiled.order, onOrder: (order) => setState({ ...state, order }), onOpen: open, onDrill: drill }))] }));
404
+ })] })) : null] }), _jsx(ViewTabs, { model: model, viewType: type, state: state, onChange: setState }), _jsx(SearchBar, { model: model, viewType: type, spec: spec.data, state: state, onChange: setState, labels: labels }), Renderer === undefined ? (_jsx(Alert, { children: `No renderer for '${served.data.view.component ?? type}'.` })) : (_jsx(Renderer, { served: served.data, where: compiled.where, groupBy: compiled.groupBy, order: compiled.order, onOrder: (order) => setState({ ...state, order }), onOpen: open, onDrill: drill }))] }));
394
405
  }
395
406
  /** What the record page needs from the shell: the API, the user, the apps (for breadcrumbs). */
396
407
  function useRecordContext() {
@@ -481,9 +492,24 @@ function ActivatePage() {
481
492
  const navigate = useNavigate();
482
493
  return _jsx(Activate, { api: useServices().api, onSignedIn: () => void navigate({ to: '/' }) });
483
494
  }
495
+ /** Where to go after signing in: `?next=` when it is a path of this site (never another site), else home. */
496
+ function nextPath() {
497
+ const next = new URLSearchParams(window.location.search).get('next') ?? '';
498
+ return next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : '/';
499
+ }
484
500
  function LoginPage() {
485
501
  const navigate = useNavigate();
486
- return _jsx(Login, { api: useServices().api, onSignedIn: () => void navigate({ to: '/' }) });
502
+ return (_jsx(Login, { api: useServices().api, onSignedIn: () => {
503
+ const next = nextPath();
504
+ // A page outside the router's own paths with its query (the OAuth consent) loads as an address.
505
+ if (next.startsWith('/oauth/'))
506
+ window.location.assign(next);
507
+ else
508
+ void navigate({ to: next });
509
+ } }));
510
+ }
511
+ function ConsentPage() {
512
+ return _jsx(Consent, { api: useServices().api });
487
513
  }
488
514
  // ------------------------------------------------------------------------------------------------- router
489
515
  const rootRoute = createRootRoute({ component: Outlet });
@@ -497,6 +523,12 @@ const activateRoute = createRoute({
497
523
  path: '/activate',
498
524
  component: ActivatePage,
499
525
  });
526
+ /** The OAuth consent page (RFC 0017 §5.2): outside the shell, like sign-in. */
527
+ const consentRoute = createRoute({
528
+ getParentRoute: () => rootRoute,
529
+ path: '/oauth/authorize',
530
+ component: ConsentPage,
531
+ });
500
532
  const shellRoute = createRoute({ getParentRoute: () => rootRoute, id: 'shell', component: Shell });
501
533
  const indexRoute = createRoute({
502
534
  getParentRoute: () => shellRoute,
@@ -546,6 +578,7 @@ const userPageRoute = createRoute({
546
578
  const routeTree = rootRoute.addChildren([
547
579
  loginRoute,
548
580
  activateRoute,
581
+ consentRoute,
549
582
  shellRoute.addChildren([
550
583
  indexRoute,
551
584
  openRoute,
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  * App icons by name (`app.icon` in a module manifest): the launcher's tiles and the App manager's cards. Names
4
4
  * follow Lucide; an unknown name shows a neutral grid, never a broken image.
5
5
  */
6
- import { BookOpen, Boxes, Briefcase, ChartColumn, FolderKanban, LayoutGrid, Mail, MessageSquare, MessagesSquare, Package, PencilRuler, Receipt, Ruler, Settings, ShieldCheck, ShoppingCart, Users, Wallet, Warehouse, Workflow, } from 'lucide-react';
6
+ import { BookOpen, Boxes, Briefcase, Building, Building2, ChartColumn, FolderKanban, LayoutGrid, LockKeyhole, Mail, MessageSquare, MessagesSquare, Package, PencilRuler, Plug, Receipt, Ruler, ScrollText, Settings, ShieldCheck, ShoppingCart, Sparkles, Users, Wallet, Warehouse, Workflow, } from 'lucide-react';
7
7
  import { cn } from '../lib/cn.js';
8
8
  const ICONS = {
9
9
  settings: Settings,
@@ -37,6 +37,16 @@ const ICONS = {
37
37
  shield: ShieldCheck,
38
38
  permissions: ShieldCheck,
39
39
  briefcase: Briefcase,
40
+ plug: Plug,
41
+ integrations: Plug,
42
+ cart: ShoppingCart,
43
+ company: Building2,
44
+ companies: Building,
45
+ security: LockKeyhole,
46
+ audit: ScrollText,
47
+ apps: LayoutGrid,
48
+ assistant: Sparkles,
49
+ sparkles: Sparkles,
40
50
  };
41
51
  /** A tinted square with the app's icon (decorative: the app's name is always shown next to it). */
42
52
  export function AppIcon({ name, className, }) {
@@ -0,0 +1,21 @@
1
+ import { type ReactElement } from 'react';
2
+ /** The URL of a photo (`user`) or logo (`company`), or null when there is none. */
3
+ export declare function imageUrl(kind: 'user' | 'company', id: number, version: string | null | undefined): string | null;
4
+ /** Up to two initials of a name. */
5
+ export declare function initialsOf(name: string): string;
6
+ export declare function Avatar({ name, src, shape, className, }: {
7
+ readonly name: string;
8
+ readonly src: string | null;
9
+ /** People are round; companies square. */
10
+ readonly shape?: 'circle' | 'square';
11
+ readonly className?: string;
12
+ }): ReactElement;
13
+ export declare function ImagePicker({ name, src, fit, onChoose, onRemove, busy, }: {
14
+ readonly name: string;
15
+ readonly src: string | null;
16
+ readonly fit: 'photo' | 'logo';
17
+ /** The image as a `data:` URL, already small. */
18
+ readonly onChoose: (dataUrl: string) => void;
19
+ readonly onRemove: () => void;
20
+ readonly busy?: boolean;
21
+ }): ReactElement;
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Photos and logos: `Avatar` shows a person's photo or a company's logo (the server's image route, versioned so the
4
+ * browser caches it), else the initials on a tint. `ImagePicker` chooses a file and makes it small in the browser —
5
+ * a 256 px square, cropped to the centre for photos, fitted whole for logos — before anything is sent.
6
+ */
7
+ import { ImageUp, Trash2 } from 'lucide-react';
8
+ import { useId, useRef, useState } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+ import { cn } from '../lib/cn.js';
11
+ import { Button } from './primitives.js';
12
+ /** The URL of a photo (`user`) or logo (`company`), or null when there is none. */
13
+ export function imageUrl(kind, id, version) {
14
+ return version === null || version === undefined
15
+ ? null
16
+ : `/api/v1/images/${kind}/${id}?v=${version}`;
17
+ }
18
+ /** Up to two initials of a name. */
19
+ export function initialsOf(name) {
20
+ return name
21
+ .split(/\s+/)
22
+ .filter((w) => w !== '')
23
+ .map((w) => w.slice(0, 1))
24
+ .join('')
25
+ .slice(0, 2)
26
+ .toUpperCase();
27
+ }
28
+ export function Avatar({ name, src, shape = 'circle', className, }) {
29
+ const [failed, setFailed] = useState(false);
30
+ const round = shape === 'circle' ? 'rounded-full' : 'rounded-md';
31
+ if (src !== null && !failed)
32
+ return (_jsx("img", { src: src, alt: "", "aria-hidden": true, onError: () => setFailed(true), className: cn('size-7 shrink-0 bg-surface object-cover', shape === 'square' && 'object-contain', round, className) }));
33
+ return (_jsx("span", { "aria-hidden": true, className: cn('grid size-7 shrink-0 place-items-center bg-primary text-xs font-medium text-primary-foreground', round, className), children: initialsOf(name) }));
34
+ }
35
+ const SIZE = 256;
36
+ /** Draws `file` on a 256 px square: covered and centred (`photo`) or fitted whole (`logo`). */
37
+ async function shrink(file, fit) {
38
+ const bitmap = await createImageBitmap(file);
39
+ const canvas = document.createElement('canvas');
40
+ canvas.width = SIZE;
41
+ canvas.height = SIZE;
42
+ const ctx = canvas.getContext('2d');
43
+ if (ctx === null)
44
+ throw new Error('No canvas');
45
+ const scale = fit === 'photo'
46
+ ? Math.max(SIZE / bitmap.width, SIZE / bitmap.height)
47
+ : Math.min(SIZE / bitmap.width, SIZE / bitmap.height);
48
+ const w = bitmap.width * scale;
49
+ const h = bitmap.height * scale;
50
+ ctx.imageSmoothingQuality = 'high';
51
+ ctx.drawImage(bitmap, (SIZE - w) / 2, (SIZE - h) / 2, w, h);
52
+ bitmap.close();
53
+ // Photos as WebP (small); logos as PNG (they keep transparency and sharp edges).
54
+ return fit === 'photo' ? canvas.toDataURL('image/webp', 0.86) : canvas.toDataURL('image/png');
55
+ }
56
+ export function ImagePicker({ name, src, fit, onChoose, onRemove, busy, }) {
57
+ const { t } = useTranslation();
58
+ const id = useId();
59
+ const input = useRef(null);
60
+ const [error, setError] = useState(null);
61
+ return (_jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(Avatar, { name: name, src: src, shape: fit === 'photo' ? 'circle' : 'square', className: "size-16 text-lg" }), _jsxs("div", { className: "grid gap-1.5", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx("input", { ref: input, id: id, type: "file", accept: "image/png,image/jpeg,image/webp", className: "sr-only", onChange: (e) => {
62
+ const file = e.target.files?.[0];
63
+ e.target.value = '';
64
+ if (file === undefined)
65
+ return;
66
+ setError(null);
67
+ shrink(file, fit)
68
+ .then(onChoose)
69
+ .catch(() => setError(t('image.unreadable')));
70
+ } }), _jsxs(Button, { size: "sm", busy: busy === true, onClick: () => input.current?.click(), children: [_jsx(ImageUp, { "aria-hidden": true }), fit === 'photo' ? t('image.choosePhoto') : t('image.chooseLogo')] }), src === null ? null : (_jsxs(Button, { size: "sm", variant: "ghost", onClick: onRemove, children: [_jsx(Trash2, { "aria-hidden": true }), t('image.remove')] }))] }), _jsx("p", { className: "text-xs text-muted-foreground", children: error ?? t('image.hint') })] })] }));
71
+ }
@@ -113,6 +113,7 @@ export declare const en: {
113
113
  compact: string;
114
114
  comfortable: string;
115
115
  emptyTitle: string;
116
+ emptyNamed: string;
116
117
  emptyHint: string;
117
118
  emptyFiltered: string;
118
119
  moved: string;
@@ -234,11 +235,35 @@ export declare const en: {
234
235
  done: string;
235
236
  recent: string;
236
237
  apps: string;
238
+ today: string;
239
+ approve: string;
240
+ review: string;
241
+ dueToday: string;
242
+ mentioned: string;
243
+ saved: string;
237
244
  };
238
245
  toast: {
239
246
  region: string;
240
247
  close: string;
241
248
  };
249
+ image: {
250
+ choosePhoto: string;
251
+ chooseLogo: string;
252
+ remove: string;
253
+ hint: string;
254
+ unreadable: string;
255
+ };
256
+ oauth: {
257
+ title: string;
258
+ as: string;
259
+ may: string;
260
+ limits: string;
261
+ back: string;
262
+ homepage: string;
263
+ already: string;
264
+ allow: string;
265
+ cancel: string;
266
+ };
242
267
  palette: {
243
268
  switchCompany: string;
244
269
  profile: string;
@@ -292,6 +317,11 @@ export declare const en: {
292
317
  custom: string;
293
318
  remove: string;
294
319
  or: string;
320
+ savedViews: string;
321
+ allRecords: string;
322
+ moreViews_one: string;
323
+ moreViews_other: string;
324
+ shared: string;
295
325
  };
296
326
  };
297
327
  type Messages = typeof en;
@@ -110,6 +110,7 @@ export const en = {
110
110
  compact: 'Compact',
111
111
  comfortable: 'Comfortable',
112
112
  emptyTitle: 'Nothing here yet',
113
+ emptyNamed: 'No {{name}} yet',
113
114
  emptyHint: 'Records matching the current search will appear here.',
114
115
  emptyFiltered: 'No record matches the current search.',
115
116
  moved: '{{name}} moved to {{column}}',
@@ -231,8 +232,32 @@ export const en = {
231
232
  done: 'Mark done',
232
233
  recent: 'Recently opened',
233
234
  apps: 'Apps',
235
+ today: 'Today, {{date}}',
236
+ approve: 'Approve {{record}}',
237
+ review: 'Review',
238
+ dueToday: 'due today',
239
+ mentioned: '{{actor}} mentioned you on {{record}}',
240
+ saved: 'Your saved views',
234
241
  },
235
242
  toast: { region: 'Notifications', close: 'Close' },
243
+ image: {
244
+ choosePhoto: 'Change photo',
245
+ chooseLogo: 'Change logo',
246
+ remove: 'Remove',
247
+ hint: 'PNG, JPEG or WebP. It is made small (256 px) before it is sent.',
248
+ unreadable: 'This file is not an image the browser can read.',
249
+ },
250
+ oauth: {
251
+ title: '{{app}} wants to use your account',
252
+ as: 'Signed in as {{name}} ({{login}}).',
253
+ may: 'It will be able to:',
254
+ limits: 'Never more than you may do yourself. You can take this back at any time in Connected apps.',
255
+ back: 'You will go back to {{host}}.',
256
+ homepage: 'About the app',
257
+ already: 'You allowed this before.',
258
+ allow: 'Allow',
259
+ cancel: 'Cancel',
260
+ },
236
261
  palette: {
237
262
  switchCompany: 'Switch to {{name}}',
238
263
  profile: 'Your profile',
@@ -281,6 +306,11 @@ export const en = {
281
306
  custom: 'Custom filter',
282
307
  remove: 'Remove {{facet}}',
283
308
  or: 'or',
309
+ savedViews: 'Saved views',
310
+ allRecords: 'All',
311
+ moreViews_one: '{{count}} more',
312
+ moreViews_other: '{{count}} more',
313
+ shared: 'Shared',
284
314
  },
285
315
  };
286
316
  export const bn = {
@@ -391,6 +421,7 @@ export const bn = {
391
421
  compact: 'সংক্ষিপ্ত',
392
422
  comfortable: 'স্বাভাবিক',
393
423
  emptyTitle: 'এখানে এখনো কিছু নেই',
424
+ emptyNamed: 'এখনো কোনো {{name}} নেই',
394
425
  emptyHint: 'বর্তমান খোঁজের সাথে মেলা রেকর্ড এখানে দেখা যাবে।',
395
426
  emptyFiltered: 'বর্তমান খোঁজের সাথে কোনো রেকর্ড মেলেনি।',
396
427
  moved: '{{name}} {{column}}-এ সরানো হয়েছে',
@@ -509,8 +540,32 @@ export const bn = {
509
540
  done: 'সম্পন্ন',
510
541
  recent: 'সম্প্রতি খোলা',
511
542
  apps: 'অ্যাপস',
543
+ today: 'আজ, {{date}}',
544
+ approve: '{{record}} অনুমোদন করুন',
545
+ review: 'দেখুন',
546
+ dueToday: 'আজকের',
547
+ mentioned: '{{actor}} আপনাকে {{record}}-এ উল্লেখ করেছেন',
548
+ saved: 'আপনার সংরক্ষিত ভিউ',
512
549
  },
513
550
  toast: { region: 'বিজ্ঞপ্তি', close: 'বন্ধ করুন' },
551
+ image: {
552
+ choosePhoto: 'ছবি বদলান',
553
+ chooseLogo: 'লোগো বদলান',
554
+ remove: 'সরান',
555
+ hint: 'PNG, JPEG বা WebP। পাঠানোর আগে ছোট (২৫৬ px) করা হয়।',
556
+ unreadable: 'এই ফাইলটি ব্রাউজার পড়তে পারে এমন ছবি নয়।',
557
+ },
558
+ oauth: {
559
+ title: '{{app}} আপনার অ্যাকাউন্ট ব্যবহার করতে চায়',
560
+ as: '{{name}} ({{login}}) হিসেবে সাইন ইন করা।',
561
+ may: 'এটি যা করতে পারবে:',
562
+ limits: 'আপনি নিজে যা পারেন তার বেশি কখনো নয়। যেকোনো সময় সংযুক্ত অ্যাপস থেকে ফিরিয়ে নিতে পারেন।',
563
+ back: 'আপনি {{host}}-এ ফিরে যাবেন।',
564
+ homepage: 'অ্যাপ সম্পর্কে',
565
+ already: 'আপনি আগে এটি অনুমতি দিয়েছিলেন।',
566
+ allow: 'অনুমতি দিন',
567
+ cancel: 'বাতিল',
568
+ },
514
569
  palette: {
515
570
  switchCompany: '{{name}}-এ যান',
516
571
  profile: 'আপনার প্রোফাইল',
@@ -559,6 +614,11 @@ export const bn = {
559
614
  custom: 'নিজস্ব ফিল্টার',
560
615
  remove: '{{facet}} সরান',
561
616
  or: 'অথবা',
617
+ savedViews: 'সংরক্ষিত ভিউ',
618
+ allRecords: 'সব',
619
+ moreViews_one: 'আরও {{count}}টি',
620
+ moreViews_other: 'আরও {{count}}টি',
621
+ shared: 'শেয়ার করা',
562
622
  },
563
623
  };
564
624
  export const ar = {
@@ -10,6 +10,7 @@ import { Registry } from './registry.js';
10
10
  export { ApiError, type ApiOptions, type AppEntry, type CurrentUser, createKoshApi, type KoshApi, type ListQuery, type MenuNode, NetworkError, type Problem, type Row, type ServedView, type Versioned, type ViewField, } from './api.js';
11
11
  export { KoshApp, type KoshAppProps, useLanguage } from './app.js';
12
12
  export { AppIcon } from './components/AppIcon.js';
13
+ export * from './components/Avatar.js';
13
14
  export * from './components/primitives.js';
14
15
  export { type KoshContextValue, KoshProvider, type ThemeChoice, useKosh, useTheme, } from './context.js';
15
16
  export { type DesignContextValue, DesignProvider, type DesignSpot, useDesign, } from './design.js';
package/dist/ui/index.js CHANGED
@@ -14,6 +14,7 @@ import { registerDefaultWidgets } from './views/widgets.js';
14
14
  export { ApiError, createKoshApi, NetworkError, } from './api.js';
15
15
  export { KoshApp, useLanguage } from './app.js';
16
16
  export { AppIcon } from './components/AppIcon.js';
17
+ export * from './components/Avatar.js';
17
18
  export * from './components/primitives.js';
18
19
  export { KoshProvider, useKosh, useTheme, } from './context.js';
19
20
  export { DesignProvider, useDesign, } from './design.js';
@@ -124,9 +124,13 @@ export declare class Registry {
124
124
  /** The widget for a node: its `widget`, else the field kind, else plain text. */
125
125
  widget(node: FieldNode, field: ViewField): ComponentType<WidgetProps>;
126
126
  component(name: string): ComponentType<NodeComponentProps> | undefined;
127
- /** An item of the shell's header, before the language and theme menus (`notify.Bell`). */
127
+ /**
128
+ * An item of the shell's header, before the language and theme menus (`notify.Bell`). With `home`, it is also in
129
+ * the home page's corner, next to the account menu (notifications, messages).
130
+ */
128
131
  registerHeaderItem(name: string, component: ComponentType<HeaderItemProps>, options?: {
129
132
  readonly replace?: boolean;
133
+ readonly home?: boolean;
130
134
  }): this;
131
135
  /**
132
136
  * A personal page every signed-in user reaches from the user menu, outside any app (`settings.ApiKeys`):
@@ -147,6 +151,8 @@ export declare class Registry {
147
151
  headerItems(): readonly {
148
152
  readonly name: string;
149
153
  readonly component: ComponentType<HeaderItemProps>;
154
+ /** Also on the home page. */
155
+ readonly home: boolean;
150
156
  }[];
151
157
  /** A control next to every search box (the AI assistant's "Ask"). */
152
158
  registerSearchAssist(name: string, component: ComponentType<SearchAssistProps>, options?: {
@@ -55,8 +55,16 @@ export class Registry {
55
55
  component(name) {
56
56
  return this.#entries.component.get(name);
57
57
  }
58
- /** An item of the shell's header, before the language and theme menus (`notify.Bell`). */
58
+ #homeItems = new Set();
59
+ /**
60
+ * An item of the shell's header, before the language and theme menus (`notify.Bell`). With `home`, it is also in
61
+ * the home page's corner, next to the account menu (notifications, messages).
62
+ */
59
63
  registerHeaderItem(name, component, options = {}) {
64
+ if (options.home === true)
65
+ this.#homeItems.add(name);
66
+ else
67
+ this.#homeItems.delete(name);
60
68
  return this.#add('header', name, component, options.replace === true);
61
69
  }
62
70
  #userPageLabels = new Map();
@@ -83,6 +91,7 @@ export class Registry {
83
91
  return [...this.#entries.header].map(([name, component]) => ({
84
92
  name,
85
93
  component: component,
94
+ home: this.#homeItems.has(name),
86
95
  }));
87
96
  }
88
97
  /** A control next to every search box (the AI assistant's "Ask"). */
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Saved views as tabs (RFC 0018 §4.3): above the search, *All* and the saved searches of this model and view type —
3
+ * the user's own and the shared ones —, the one matching the current search marked. A tab applies its search; more
4
+ * than a handful go into a *More* menu. Saving and the full list stay in the search bar's favorites menu; with
5
+ * nothing saved, there are no tabs.
6
+ */
7
+ import { type SearchState } from '../../search/index.js';
8
+ import type { ReactElement } from 'react';
9
+ export declare function ViewTabs({ model, viewType, state, onChange, }: {
10
+ readonly model: string;
11
+ readonly viewType: string;
12
+ readonly state: SearchState;
13
+ readonly onChange: (state: SearchState) => void;
14
+ }): ReactElement | null;