@object-ui/app-shell 5.1.1 → 5.3.0
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 +322 -0
- package/dist/chrome/CommandPalette.d.ts +6 -1
- package/dist/chrome/CommandPalette.js +40 -4
- package/dist/chrome/ConsoleToaster.js +8 -1
- package/dist/chrome/ErrorBoundary.js +3 -0
- package/dist/chrome/RouteFader.d.ts +32 -0
- package/dist/chrome/RouteFader.js +52 -0
- package/dist/chrome/index.d.ts +2 -0
- package/dist/chrome/index.js +2 -0
- package/dist/chrome/toast-helpers.d.ts +48 -0
- package/dist/chrome/toast-helpers.js +59 -0
- package/dist/console/AppContent.js +108 -2
- package/dist/console/home/HomePage.js +5 -4
- package/dist/console/marketplace/MarkdownText.d.ts +19 -0
- package/dist/console/marketplace/MarkdownText.js +141 -0
- package/dist/console/marketplace/MarketplaceInstalledPage.d.ts +17 -0
- package/dist/console/marketplace/MarketplaceInstalledPage.js +64 -0
- package/dist/console/marketplace/MarketplacePackagePage.d.ts +7 -0
- package/dist/console/marketplace/MarketplacePackagePage.js +200 -0
- package/dist/console/marketplace/MarketplacePage.d.ts +8 -0
- package/dist/console/marketplace/MarketplacePage.js +94 -0
- package/dist/console/marketplace/PackageIcon.d.ts +19 -0
- package/dist/console/marketplace/PackageIcon.js +17 -0
- package/dist/console/marketplace/marketplaceApi.d.ts +122 -0
- package/dist/console/marketplace/marketplaceApi.js +207 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +6 -5
- package/dist/layout/AppHeader.js +16 -2
- package/dist/layout/AppSidebar.js +15 -11
- package/dist/layout/InboxPopover.js +43 -3
- package/dist/observability/index.d.ts +9 -0
- package/dist/observability/index.js +9 -0
- package/dist/observability/sentry.d.ts +46 -0
- package/dist/observability/sentry.js +120 -0
- package/dist/types.d.ts +0 -46
- package/dist/views/RecordDetailView.js +79 -15
- package/package.json +26 -25
- package/src/styles.css +49 -0
- package/dist/components/DashboardRenderer.d.ts +0 -8
- package/dist/components/DashboardRenderer.js +0 -16
- package/dist/components/FormRenderer.d.ts +0 -8
- package/dist/components/FormRenderer.js +0 -31
- package/dist/components/ObjectRenderer.d.ts +0 -8
- package/dist/components/ObjectRenderer.js +0 -74
- package/dist/components/PageRenderer.d.ts +0 -7
- package/dist/components/PageRenderer.js +0 -14
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace REST helper.
|
|
3
|
+
*
|
|
4
|
+
* Thin fetch wrapper around the tenant runtime's marketplace proxy
|
|
5
|
+
* (`/api/v1/marketplace/*`). The proxy forwards to the configured
|
|
6
|
+
* cloud control plane (`OS_CLOUD_URL`) — only browse endpoints are
|
|
7
|
+
* exposed. Install is performed against the cloud action endpoint
|
|
8
|
+
* directly (via `installPackage()` below), which requires the user
|
|
9
|
+
* to have a cloud session.
|
|
10
|
+
*
|
|
11
|
+
* See:
|
|
12
|
+
* cloud/packages/service-cloud/src/routes/marketplace.ts
|
|
13
|
+
* framework/packages/runtime/src/cloud/marketplace-proxy-plugin.ts
|
|
14
|
+
*/
|
|
15
|
+
const SERVER_URL = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
|
|
16
|
+
const API_BASE = `${SERVER_URL}/api/v1/marketplace`;
|
|
17
|
+
async function call(path, init) {
|
|
18
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
19
|
+
credentials: 'omit',
|
|
20
|
+
headers: { 'Accept': 'application/json', ...(init?.headers || {}) },
|
|
21
|
+
...init,
|
|
22
|
+
});
|
|
23
|
+
let payload = null;
|
|
24
|
+
try {
|
|
25
|
+
payload = await res.json();
|
|
26
|
+
}
|
|
27
|
+
catch { /* empty body */ }
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const code = payload?.error?.code ?? payload?.code ?? `HTTP_${res.status}`;
|
|
30
|
+
const message = payload?.error?.message ?? payload?.error ?? res.statusText;
|
|
31
|
+
const err = new Error(typeof message === 'string' ? message : `${code}`);
|
|
32
|
+
err.code = code;
|
|
33
|
+
err.status = res.status;
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
// Cloud helpers return `{ success: true, data: ... }`; unwrap when present.
|
|
37
|
+
return (payload?.data ?? payload);
|
|
38
|
+
}
|
|
39
|
+
export async function listMarketplacePackages(params = {}) {
|
|
40
|
+
const usp = new URLSearchParams();
|
|
41
|
+
if (params.q)
|
|
42
|
+
usp.set('q', params.q);
|
|
43
|
+
if (params.category)
|
|
44
|
+
usp.set('category', params.category);
|
|
45
|
+
if (params.limit != null)
|
|
46
|
+
usp.set('limit', String(params.limit));
|
|
47
|
+
if (params.offset != null)
|
|
48
|
+
usp.set('offset', String(params.offset));
|
|
49
|
+
const qs = usp.toString();
|
|
50
|
+
return call(`/packages${qs ? `?${qs}` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
export async function getMarketplacePackage(id) {
|
|
53
|
+
return call(`/packages/${encodeURIComponent(id)}`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Install a package into an environment by calling the cloud's
|
|
57
|
+
* `install_package` action **directly** (not via the proxy). Requires the
|
|
58
|
+
* caller's browser to have a valid cloud session cookie — typically
|
|
59
|
+
* because the user has signed into cloud at least once and the cookie
|
|
60
|
+
* domain covers this origin. When that's not the case the call fails
|
|
61
|
+
* with 401; the UI can then surface a "Sign in on cloud" link.
|
|
62
|
+
*
|
|
63
|
+
* `cloudBaseUrl` is read from `VITE_CLOUD_URL` if set; otherwise we fall
|
|
64
|
+
* back to relative (assumes same-origin, which is true for cloud-hosted
|
|
65
|
+
* consoles but generally not for tenant runtimes).
|
|
66
|
+
*/
|
|
67
|
+
const CLOUD_BASE = (import.meta.env.VITE_CLOUD_URL || '').replace(/\/$/, '');
|
|
68
|
+
export async function installPackage(input) {
|
|
69
|
+
const base = CLOUD_BASE || SERVER_URL;
|
|
70
|
+
const res = await fetch(`${base}/api/v1/actions/sys_package/install_package`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
credentials: 'include',
|
|
73
|
+
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
recordId: input.packageId,
|
|
76
|
+
params: {
|
|
77
|
+
environment_id: input.environmentId,
|
|
78
|
+
seed_sample_data: !!input.seedSampleData,
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
let payload = null;
|
|
83
|
+
try {
|
|
84
|
+
payload = await res.json();
|
|
85
|
+
}
|
|
86
|
+
catch { /* empty */ }
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
const code = payload?.code ?? payload?.error?.code ?? `HTTP_${res.status}`;
|
|
89
|
+
const message = payload?.error ?? payload?.message ?? res.statusText;
|
|
90
|
+
const err = new Error(typeof message === 'string' ? message : `${code}`);
|
|
91
|
+
err.code = code;
|
|
92
|
+
err.status = res.status;
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
return (payload?.data ?? payload);
|
|
96
|
+
}
|
|
97
|
+
export async function listCloudEnvironments() {
|
|
98
|
+
const base = CLOUD_BASE || SERVER_URL;
|
|
99
|
+
const res = await fetch(`${base}/api/v1/data/sys_environment?limit=200`, {
|
|
100
|
+
credentials: 'include',
|
|
101
|
+
headers: { 'Accept': 'application/json' },
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const err = new Error(`HTTP_${res.status}`);
|
|
105
|
+
err.status = res.status;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
const payload = await res.json().catch(() => ({}));
|
|
109
|
+
const rows = payload?.data ?? payload?.items ?? payload ?? [];
|
|
110
|
+
return Array.isArray(rows) ? rows : [];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* List orgs in which the current cloud user has an `owner` or `admin`
|
|
114
|
+
* role on `sys_member`. Used to filter the install dialog's env picker
|
|
115
|
+
* — only envs whose `organization_id` is in this set are installable
|
|
116
|
+
* (the backend enforces the same gate; this is the UX mirror).
|
|
117
|
+
*
|
|
118
|
+
* Returns an empty set on 401 / network failure so the install dialog
|
|
119
|
+
* can render a clean "no installable environments" state.
|
|
120
|
+
*/
|
|
121
|
+
export async function listInstallableOrgIds() {
|
|
122
|
+
const base = CLOUD_BASE || SERVER_URL;
|
|
123
|
+
// sys_member rows are scoped to the caller; better-auth-managed table.
|
|
124
|
+
const url = `${base}/api/v1/data/sys_member?limit=200`;
|
|
125
|
+
let payload = null;
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch(url, {
|
|
128
|
+
credentials: 'include',
|
|
129
|
+
headers: { 'Accept': 'application/json' },
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok)
|
|
132
|
+
return new Set();
|
|
133
|
+
payload = await res.json().catch(() => ({}));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return new Set();
|
|
137
|
+
}
|
|
138
|
+
const rows = payload?.data ?? payload?.items ?? payload ?? [];
|
|
139
|
+
if (!Array.isArray(rows))
|
|
140
|
+
return new Set();
|
|
141
|
+
const ids = new Set();
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
const role = String(row?.role ?? '').toLowerCase();
|
|
144
|
+
const orgId = row?.organization_id ?? row?.organizationId;
|
|
145
|
+
if (orgId && (role === 'owner' || role === 'admin')) {
|
|
146
|
+
ids.add(String(orgId));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return ids;
|
|
150
|
+
}
|
|
151
|
+
export function cloudInstallDeepLink(packageId) {
|
|
152
|
+
const base = CLOUD_BASE || 'https://cloud.objectos.app';
|
|
153
|
+
return `${base}/apps/cloud-control/sys_package/${encodeURIComponent(packageId)}`;
|
|
154
|
+
}
|
|
155
|
+
export async function installLocal(input) {
|
|
156
|
+
const res = await fetch(`${API_BASE}/install-local`, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
credentials: 'include',
|
|
159
|
+
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
packageId: input.packageId,
|
|
162
|
+
...(input.versionId ? { versionId: input.versionId } : {}),
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
let payload = null;
|
|
166
|
+
try {
|
|
167
|
+
payload = await res.json();
|
|
168
|
+
}
|
|
169
|
+
catch { /* empty */ }
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
const code = payload?.error?.code ?? `HTTP_${res.status}`;
|
|
172
|
+
const message = payload?.error?.message ?? res.statusText;
|
|
173
|
+
const err = new Error(typeof message === 'string' ? message : `${code}`);
|
|
174
|
+
err.code = code;
|
|
175
|
+
err.status = res.status;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
return (payload?.data ?? payload);
|
|
179
|
+
}
|
|
180
|
+
export async function listLocalInstalls() {
|
|
181
|
+
try {
|
|
182
|
+
const res = await fetch(`${API_BASE}/install-local`, {
|
|
183
|
+
credentials: 'include',
|
|
184
|
+
headers: { 'Accept': 'application/json' },
|
|
185
|
+
});
|
|
186
|
+
if (!res.ok)
|
|
187
|
+
return [];
|
|
188
|
+
const payload = await res.json().catch(() => ({}));
|
|
189
|
+
const items = payload?.data?.items ?? payload?.items ?? [];
|
|
190
|
+
return Array.isArray(items) ? items : [];
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export async function uninstallLocal(manifestId) {
|
|
197
|
+
const res = await fetch(`${API_BASE}/install-local/${encodeURIComponent(manifestId)}`, {
|
|
198
|
+
method: 'DELETE',
|
|
199
|
+
credentials: 'include',
|
|
200
|
+
headers: { 'Accept': 'application/json' },
|
|
201
|
+
});
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
const payload = await res.json().catch(() => ({}));
|
|
204
|
+
const message = payload?.error?.message ?? res.statusText;
|
|
205
|
+
throw new Error(typeof message === 'string' ? message : `HTTP_${res.status}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,23 +5,20 @@
|
|
|
5
5
|
* Framework-agnostic rendering engine for third-party integration
|
|
6
6
|
*/
|
|
7
7
|
export { AppShell } from './components/AppShell';
|
|
8
|
-
export { ObjectRenderer } from './components/ObjectRenderer';
|
|
9
|
-
export { DashboardRenderer } from './components/DashboardRenderer';
|
|
10
|
-
export { PageRenderer } from './components/PageRenderer';
|
|
11
|
-
export { FormRenderer } from './components/FormRenderer';
|
|
12
8
|
export { AdapterProvider, useAdapter } from './providers/AdapterProvider';
|
|
13
9
|
export { MetadataProvider, useMetadata, useMetadataItem } from './providers/MetadataProvider';
|
|
14
10
|
export { ExpressionProvider, useExpressionContext, evaluateVisibility } from './providers/ExpressionProvider';
|
|
15
11
|
export { useObjectActions } from './hooks/useObjectActions';
|
|
16
12
|
export { useRecentItems } from './hooks/useRecentItems';
|
|
17
|
-
export type { AppShellProps,
|
|
13
|
+
export type { AppShellProps, } from './types';
|
|
18
14
|
export type { MetadataState, MetadataContextValue, MetadataTypeStatus, } from './providers/MetadataProvider';
|
|
19
15
|
export type { ExpressionContextValue, } from './providers/ExpressionProvider';
|
|
20
16
|
export type { RecentItem, } from './hooks/useRecentItems';
|
|
21
17
|
export { ConsoleShell, ConnectedShell, RequireOrganization, AuthenticatedRoute, RootRedirect, SystemRedirect, LoadingFallback, } from './console/ConsoleShell';
|
|
22
18
|
export { ConsoleLayout, AppHeader, AppSidebar, UnifiedSidebar, AppSwitcher, ConnectionStatus, ActivityFeed, LocaleSwitcher, ModeToggle, AuthPageLayout, } from './layout';
|
|
23
19
|
export type { ActivityItem } from './layout';
|
|
24
|
-
export { CommandPalette, KeyboardShortcutsDialog, OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, ErrorBoundary, LoadingScreen, ThemeProvider, useTheme, } from './chrome';
|
|
20
|
+
export { CommandPalette, KeyboardShortcutsDialog, OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, RouteFader, toastWithUndo, type ToastWithUndoOptions, ErrorBoundary, LoadingScreen, ThemeProvider, useTheme, } from './chrome';
|
|
21
|
+
export { initSentry, captureError, setSentryUser, getSentry } from './observability';
|
|
25
22
|
export { ObjectView, RecordDetailView, RecordFormPage, DashboardView, PageView, ReportView, SearchResultsPage, ViewConfigPanel, } from './views';
|
|
26
23
|
export type { RecordFormPageProps } from './views';
|
|
27
24
|
export { useFavorites, useMetadataService, useNavPins, useNavigationSync, NavigationSyncEffect, addNavigationItem, removeNavigationItems, renameNavigationItems, navigationEqual, generateNavId, useResponsiveSidebar, } from './hooks';
|
|
@@ -29,6 +26,9 @@ export type { FavoriteItem } from './hooks';
|
|
|
29
26
|
export { NavigationProvider, useNavigationContext, FavoritesProvider, RecentItemsProvider, UserStateAdaptersProvider, useUserStateAdapter, useAttachUserStateAdapters, } from './context';
|
|
30
27
|
export type { UserDataAdapter, UserStateKind } from './context';
|
|
31
28
|
export { AppContent as DefaultAppContent } from './console/AppContent';
|
|
29
|
+
export { MarketplacePage } from './console/marketplace/MarketplacePage';
|
|
30
|
+
export { MarketplacePackagePage } from './console/marketplace/MarketplacePackagePage';
|
|
31
|
+
export { MarketplaceInstalledPage } from './console/marketplace/MarketplaceInstalledPage';
|
|
32
32
|
export { LoginPage as DefaultLoginPage } from './console/auth/LoginPage';
|
|
33
33
|
export { RegisterPage as DefaultRegisterPage } from './console/auth/RegisterPage';
|
|
34
34
|
export { ForgotPasswordPage as DefaultForgotPasswordPage } from './console/auth/ForgotPasswordPage';
|
package/dist/index.js
CHANGED
|
@@ -6,10 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
// Components
|
|
8
8
|
export { AppShell } from './components/AppShell';
|
|
9
|
-
export { ObjectRenderer } from './components/ObjectRenderer';
|
|
10
|
-
export { DashboardRenderer } from './components/DashboardRenderer';
|
|
11
|
-
export { PageRenderer } from './components/PageRenderer';
|
|
12
|
-
export { FormRenderer } from './components/FormRenderer';
|
|
13
9
|
// Providers
|
|
14
10
|
export { AdapterProvider, useAdapter } from './providers/AdapterProvider';
|
|
15
11
|
export { MetadataProvider, useMetadata, useMetadataItem } from './providers/MetadataProvider';
|
|
@@ -23,7 +19,9 @@ export { ConsoleShell, ConnectedShell, RequireOrganization, AuthenticatedRoute,
|
|
|
23
19
|
// Layout chrome
|
|
24
20
|
export { ConsoleLayout, AppHeader, AppSidebar, UnifiedSidebar, AppSwitcher, ConnectionStatus, ActivityFeed, LocaleSwitcher, ModeToggle, AuthPageLayout, } from './layout';
|
|
25
21
|
// Top-level chrome (dialogs, providers, error boundaries)
|
|
26
|
-
export { CommandPalette, KeyboardShortcutsDialog, OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, ErrorBoundary, LoadingScreen, ThemeProvider, useTheme, } from './chrome';
|
|
22
|
+
export { CommandPalette, KeyboardShortcutsDialog, OnboardingWalkthrough, ConditionalAuthWrapper, ConsoleToaster, RouteFader, toastWithUndo, ErrorBoundary, LoadingScreen, ThemeProvider, useTheme, } from './chrome';
|
|
23
|
+
// Observability — Sentry integration, opt-in via VITE_SENTRY_DSN
|
|
24
|
+
export { initSentry, captureError, setSentryUser, getSentry } from './observability';
|
|
27
25
|
// Standard inner-SPA views
|
|
28
26
|
export { ObjectView, RecordDetailView, RecordFormPage, DashboardView, PageView, ReportView, SearchResultsPage, ViewConfigPanel, } from './views';
|
|
29
27
|
// Hooks
|
|
@@ -32,6 +30,9 @@ export { useFavorites, useMetadataService, useNavPins, useNavigationSync, Naviga
|
|
|
32
30
|
export { NavigationProvider, useNavigationContext, FavoritesProvider, RecentItemsProvider, UserStateAdaptersProvider, useUserStateAdapter, useAttachUserStateAdapters, } from './context';
|
|
33
31
|
// Default page implementations (consumers can partial-override slots)
|
|
34
32
|
export { AppContent as DefaultAppContent } from './console/AppContent';
|
|
33
|
+
export { MarketplacePage } from './console/marketplace/MarketplacePage';
|
|
34
|
+
export { MarketplacePackagePage } from './console/marketplace/MarketplacePackagePage';
|
|
35
|
+
export { MarketplaceInstalledPage } from './console/marketplace/MarketplaceInstalledPage';
|
|
35
36
|
export { LoginPage as DefaultLoginPage } from './console/auth/LoginPage';
|
|
36
37
|
export { RegisterPage as DefaultRegisterPage } from './console/auth/RegisterPage';
|
|
37
38
|
export { ForgotPasswordPage as DefaultForgotPasswordPage } from './console/auth/ForgotPasswordPage';
|
package/dist/layout/AppHeader.js
CHANGED
|
@@ -22,7 +22,7 @@ import { SidebarTrigger, Button, DropdownMenu, DropdownMenuTrigger, DropdownMenu
|
|
|
22
22
|
import { Search, HelpCircle, ChevronDown, Check, Lock, Settings, LogOut, User as UserIcon, Boxes, } from 'lucide-react';
|
|
23
23
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
24
24
|
import { useOffline } from '@object-ui/react';
|
|
25
|
-
import { PresenceAvatars } from '@object-ui/collaboration';
|
|
25
|
+
import { PresenceAvatars, useTenantPresence } from '@object-ui/collaboration';
|
|
26
26
|
import { ModeToggle } from './ModeToggle';
|
|
27
27
|
import { LocaleSwitcher } from './LocaleSwitcher';
|
|
28
28
|
import { ConnectionStatus } from './ConnectionStatus';
|
|
@@ -265,9 +265,23 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
265
265
|
if (!dataSource)
|
|
266
266
|
return;
|
|
267
267
|
const now = new Date().toISOString();
|
|
268
|
+
const ids = unread.map(n => n.id);
|
|
269
|
+
// Prefer the bulk endpoint — one HTTP/auth/RLS round-trip instead of
|
|
270
|
+
// N parallel PATCHes, which previously caused noticeable hitching
|
|
271
|
+
// (Slack-style "mark all read" jank) on inboxes with 50+ unread.
|
|
272
|
+
// Fall back to the per-id loop on adapters that don't implement it.
|
|
273
|
+
const ds = dataSource;
|
|
274
|
+
if (typeof ds.bulkUpdate === 'function') {
|
|
275
|
+
try {
|
|
276
|
+
await ds.bulkUpdate('sys_notification', ids, { is_read: true, read_at: now });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
catch { /* fall through to per-id loop */ }
|
|
280
|
+
}
|
|
268
281
|
await Promise.all(unread.map(n => dataSource.update('sys_notification', n.id, { is_read: true, read_at: now }).catch(() => { })));
|
|
269
282
|
}, [dataSource, notifications]);
|
|
270
|
-
const
|
|
283
|
+
const tenantPresence = useTenantPresence();
|
|
284
|
+
const activeUsers = presenceUsers ?? (tenantPresence.length > 0 ? tenantPresence : EMPTY_PRESENCE_USERS);
|
|
271
285
|
const activeActivities = activities ?? apiActivities ?? [];
|
|
272
286
|
const orgList = organizations ?? [];
|
|
273
287
|
const hasOrgSection = isOrganizationsLoading || orgList.length > 0 || !!activeOrganization;
|
|
@@ -18,7 +18,7 @@ import { ChevronsUpDown, Plus, Settings, LogOut, Database, Clock, Star, StarOff,
|
|
|
18
18
|
import { NavigationRenderer } from '@object-ui/layout';
|
|
19
19
|
import { useMetadata } from '../providers/MetadataProvider';
|
|
20
20
|
import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider';
|
|
21
|
-
import { useAuth, getUserInitials } from '@object-ui/auth';
|
|
21
|
+
import { useAuth, useIsWorkspaceAdmin, getUserInitials } from '@object-ui/auth';
|
|
22
22
|
import { usePermissions } from '@object-ui/permissions';
|
|
23
23
|
import { useRecentItems } from '../hooks/useRecentItems';
|
|
24
24
|
import { useFavorites } from '../hooks/useFavorites';
|
|
@@ -92,6 +92,7 @@ const getIcon = resolveIcon;
|
|
|
92
92
|
export function AppSidebar({ activeAppName, onAppChange }) {
|
|
93
93
|
const { isMobile } = useSidebar();
|
|
94
94
|
const { user, signOut, isAuthEnabled } = useAuth();
|
|
95
|
+
const isWorkspaceAdmin = useIsWorkspaceAdmin();
|
|
95
96
|
const navigate = useNavigate();
|
|
96
97
|
const { t } = useObjectTranslation();
|
|
97
98
|
const { objectLabel: resolveNavObjectLabel, viewLabel: resolveNavViewLabel } = useObjectLabel();
|
|
@@ -186,16 +187,19 @@ export function AppSidebar({ activeAppName, onAppChange }) {
|
|
|
186
187
|
}, [registeredObjectNames]);
|
|
187
188
|
const basePath = activeApp ? `/apps/${activeAppName}` : '';
|
|
188
189
|
// Fallback system navigation when no active app exists — routes into the Setup app.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
190
|
+
// The marketplace entry is hidden from non-admin members (install is gated to
|
|
191
|
+
// owner/admin on the server, so non-admins have no reason to see it).
|
|
192
|
+
const systemFallbackNavigation = React.useMemo(() => {
|
|
193
|
+
const items = [
|
|
194
|
+
{ id: 'sys-settings', label: 'System Settings', type: 'url', url: '/apps/setup', icon: 'settings' },
|
|
195
|
+
{ id: 'sys-apps', label: 'Applications', type: 'url', url: '/apps/setup/system/apps', icon: 'layout-grid' },
|
|
196
|
+
];
|
|
197
|
+
if (isWorkspaceAdmin) {
|
|
198
|
+
items.push({ id: 'sys-marketplace', label: 'App Marketplace', type: 'url', url: '/apps/setup/system/marketplace', icon: 'store' });
|
|
199
|
+
}
|
|
200
|
+
items.push({ id: 'sys-objects', label: 'Object Manager', type: 'url', url: '/apps/setup/system/metadata/object', icon: 'database' }, { id: 'sys-users', label: 'Users', type: 'url', url: '/apps/setup/system/users', icon: 'users' }, { id: 'sys-orgs', label: 'Organizations', type: 'url', url: '/apps/setup/system/organizations', icon: 'building-2' }, { id: 'sys-roles', label: 'Roles', type: 'url', url: '/apps/setup/system/roles', icon: 'shield' }, { id: 'sys-config', label: 'Configuration', type: 'url', url: '/apps/setup/system/settings', icon: 'sliders-horizontal' }, { id: 'sys-create-app', label: 'Create App', type: 'url', url: '/create-app', icon: 'plus' });
|
|
201
|
+
return items;
|
|
202
|
+
}, [isWorkspaceAdmin]);
|
|
199
203
|
return (_jsxs(_Fragment, { children: [_jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx(SidebarMenu, { children: _jsx(SidebarMenuItem, { children: activeApp ? (_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: [_jsx("div", { className: "flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground", style: primaryColor ? { backgroundColor: primaryColor } : undefined, children: logo ? (_jsx("img", { src: logo, alt: resolveI18nLabel(activeApp.label, t), className: "size-6 object-contain" })) : (React.createElement(getIcon(activeApp.icon), { className: "size-4" })) }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: resolveI18nLabel(activeApp.label, t) }), _jsx("span", { className: "truncate text-xs", children: resolveI18nLabel(activeApp.description, t) || `${activeApps.length} Apps Available` })] }), _jsx(ChevronsUpDown, { className: "ml-auto" })] }) }), _jsxs(DropdownMenuContent, { className: "w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg", align: "start", side: isMobile ? "bottom" : "right", sideOffset: 4, children: [_jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: "Switch Application" }), activeApps.map((app) => (_jsxs(DropdownMenuItem, { onClick: () => onAppChange(app.name), className: "gap-2 p-2", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-sm border", children: app.icon ? React.createElement(getIcon(app.icon), { className: "size-3" }) : _jsx(Database, { className: "size-3" }) }), resolveI18nLabel(app.label, t), activeApp.name === app.name && _jsx("span", { className: "ml-auto text-xs", children: "\u2713" })] }, app.name))), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate('/home'), "data-testid": "home-link-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Home, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.home') })] }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate(`/apps/${activeAppName}/create-app`), "data-testid": "add-app-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Plus, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.addApp') })] }), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate(`/apps/${activeAppName}/edit-app/${activeAppName}`), "data-testid": "edit-app-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Pencil, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.editApp') })] }), _jsxs(DropdownMenuItem, { className: "gap-2 p-2", onClick: () => navigate('/apps/setup/system/apps'), "data-testid": "manage-all-apps-btn", children: [_jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-background", children: _jsx(Settings, { className: "size-4" }) }), _jsx("div", { className: "font-medium text-muted-foreground", children: t('layout.appSwitcher.manageAllApps') })] })] })] })) : (_jsxs(SidebarMenuButton, { size: "lg", onClick: () => navigate('/apps/setup'), "data-testid": "system-sidebar-header", children: [_jsx("div", { className: "flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground", children: _jsx(Settings, { className: "size-4" }) }), _jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [_jsx("span", { className: "truncate font-semibold", children: t('layout.appSwitcher.systemConsole') }), _jsx("span", { className: "truncate text-xs text-muted-foreground", children: t('layout.appSwitcher.noAppsConfigured') })] })] })) }) }) }), _jsx(SidebarContent, { children: activeApp ? (_jsxs(_Fragment, { children: [areas.length > 1 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), "Area"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: areas.map((area) => {
|
|
200
204
|
const AreaIcon = getIcon(area.icon);
|
|
201
205
|
const isActiveArea = area.id === activeAreaId;
|
|
@@ -14,7 +14,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
14
14
|
*
|
|
15
15
|
* @module
|
|
16
16
|
*/
|
|
17
|
-
import { useState } from 'react';
|
|
17
|
+
import { useState, useEffect, useRef } from 'react';
|
|
18
18
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
19
19
|
import { Button, Popover, PopoverContent, PopoverTrigger, Tabs, TabsList, TabsTrigger, TabsContent, } from '@object-ui/components';
|
|
20
20
|
import { Bell, CheckSquare, Activity as ActivityIcon } from 'lucide-react';
|
|
@@ -42,8 +42,34 @@ export function InboxPopover({ notifications, unreadCount, pendingApprovalsCount
|
|
|
42
42
|
const { currentAppName } = useNavigationContext();
|
|
43
43
|
const [open, setOpen] = useState(false);
|
|
44
44
|
const [tab, setTab] = useState('notifications');
|
|
45
|
+
// Sub-filter inside Notifications: default to Unread so users see what
|
|
46
|
+
// actually needs their attention first. The popover caps at 20 rows from
|
|
47
|
+
// the server (`?view=mine` already scopes to current user), so we filter
|
|
48
|
+
// client-side — switching tabs never re-fetches.
|
|
49
|
+
const [notifFilter, setNotifFilter] = useState('unread');
|
|
45
50
|
const totalBadge = unreadCount + pendingApprovalsCount;
|
|
46
51
|
const ariaLabel = t('sidebar.inboxAriaLabel', { defaultValue: 'Open inbox' });
|
|
52
|
+
// Pulse the bell once whenever the unread/approval pressure increases.
|
|
53
|
+
// We track the previous total in a ref so the very first render (when
|
|
54
|
+
// the counts arrive from the server) doesn't trigger a spurious pulse.
|
|
55
|
+
const prevTotalRef = useRef(null);
|
|
56
|
+
const [pulse, setPulse] = useState(false);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const prev = prevTotalRef.current;
|
|
59
|
+
if (prev !== null && totalBadge > prev) {
|
|
60
|
+
setPulse(true);
|
|
61
|
+
const timeout = window.setTimeout(() => setPulse(false), 1200);
|
|
62
|
+
return () => window.clearTimeout(timeout);
|
|
63
|
+
}
|
|
64
|
+
prevTotalRef.current = totalBadge;
|
|
65
|
+
return undefined;
|
|
66
|
+
}, [totalBadge]);
|
|
67
|
+
// Keep prev in sync after the pulse window so a subsequent increase
|
|
68
|
+
// (e.g. 3 → 5 right after 5 settled) re-triggers the animation.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!pulse)
|
|
71
|
+
prevTotalRef.current = totalBadge;
|
|
72
|
+
}, [pulse, totalBadge]);
|
|
47
73
|
const goToApprovals = () => {
|
|
48
74
|
setOpen(false);
|
|
49
75
|
const app = currentAppName ?? params.appName;
|
|
@@ -75,10 +101,24 @@ export function InboxPopover({ notifications, unreadCount, pendingApprovalsCount
|
|
|
75
101
|
navigate(target);
|
|
76
102
|
}
|
|
77
103
|
};
|
|
78
|
-
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className:
|
|
104
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "icon", className: `h-8 w-8 relative shrink-0 ${pulse ? 'motion-safe:animate-bounce' : ''}`, "aria-label": ariaLabel, title: t('sidebar.inbox', { defaultValue: 'Inbox' }), children: [_jsx(Bell, { className: "h-4 w-4" }), totalBadge > 0 && (_jsx("span", { className: "absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-red-500 text-[10px] leading-4 text-white text-center px-1 motion-safe:animate-in motion-safe:zoom-in-50 motion-safe:fade-in-0 motion-safe:duration-200", children: totalBadge > 9 ? '9+' : totalBadge }, totalBadge))] }) }), _jsxs(PopoverContent, { align: "end", sideOffset: 8, className: "w-96 p-0", children: [_jsxs("div", { className: "flex items-center justify-between px-3 pt-3 pb-1", children: [_jsx("div", { className: "text-sm font-semibold", children: t('sidebar.inbox', { defaultValue: 'Inbox' }) }), tab === 'notifications' && unreadCount > 0 && (_jsx("button", { type: "button", onClick: onMarkAllRead, className: "text-xs text-muted-foreground hover:text-foreground", children: t('notifications.markAllRead', { defaultValue: 'Mark all read' }) }))] }), _jsxs(Tabs, { value: tab, onValueChange: (v) => setTab(v), className: "w-full", children: [_jsxs(TabsList, { className: "w-full justify-start rounded-none border-b bg-transparent px-1 h-9", children: [_jsxs(TabsTrigger, { value: "notifications", className: "text-xs gap-1.5 data-[state=active]:bg-transparent", children: [_jsx(Bell, { className: "h-3.5 w-3.5" }), t('sidebar.notifications', { defaultValue: 'Notifications' }), unreadCount > 0 && (_jsx("span", { className: "ml-0.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] text-white motion-safe:animate-in motion-safe:zoom-in-75 motion-safe:duration-200", children: unreadCount > 9 ? '9+' : unreadCount }, `notif-${unreadCount}`))] }), _jsxs(TabsTrigger, { value: "approvals", className: "text-xs gap-1.5 data-[state=active]:bg-transparent", children: [_jsx(CheckSquare, { className: "h-3.5 w-3.5" }), t('sidebar.approvals', { defaultValue: 'Approvals' }), pendingApprovalsCount > 0 && (_jsx("span", { className: "ml-0.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] text-white motion-safe:animate-in motion-safe:zoom-in-75 motion-safe:duration-200", children: pendingApprovalsCount > 9 ? '9+' : pendingApprovalsCount }, `appr-${pendingApprovalsCount}`))] }), _jsxs(TabsTrigger, { value: "activity", className: "text-xs gap-1.5 data-[state=active]:bg-transparent", children: [_jsx(ActivityIcon, { className: "h-3.5 w-3.5" }), t('sidebar.activityFeed', { defaultValue: 'Activity' })] })] }), _jsxs(TabsContent, { value: "notifications", className: "m-0 max-h-80 overflow-auto", children: [_jsxs("div", { className: "flex items-center gap-1 border-b px-2 py-1.5", children: [_jsxs("button", { type: "button", onClick: () => setNotifFilter('unread'), className: `text-xs px-2 py-1 rounded-md transition-colors ${notifFilter === 'unread'
|
|
105
|
+
? 'bg-accent text-foreground font-medium'
|
|
106
|
+
: 'text-muted-foreground hover:text-foreground'}`, children: [t('notifications.filterUnread', { defaultValue: 'Unread' }), unreadCount > 0 && (_jsx("span", { className: "ml-1 text-[10px] opacity-70", children: unreadCount }))] }), _jsx("button", { type: "button", onClick: () => setNotifFilter('all'), className: `text-xs px-2 py-1 rounded-md transition-colors ${notifFilter === 'all'
|
|
107
|
+
? 'bg-accent text-foreground font-medium'
|
|
108
|
+
: 'text-muted-foreground hover:text-foreground'}`, children: t('notifications.filterAll', { defaultValue: 'All' }) })] }), (() => {
|
|
109
|
+
const visible = notifFilter === 'unread'
|
|
110
|
+
? notifications.filter((n) => !n.is_read)
|
|
111
|
+
: notifications;
|
|
112
|
+
if (visible.length === 0) {
|
|
113
|
+
return (_jsx("div", { className: "px-3 py-8 text-sm text-muted-foreground text-center motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300", children: notifFilter === 'unread'
|
|
114
|
+
? t('notifications.emptyUnread', { defaultValue: "You're all caught up" })
|
|
115
|
+
: t('notifications.empty', { defaultValue: 'No notifications' }) }));
|
|
116
|
+
}
|
|
117
|
+
return (_jsx("ul", { className: "divide-y", children: visible.map((n, idx) => (_jsx("li", { className: "motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200", style: { animationDelay: `${Math.min(idx, 6) * 20}ms` }, children: _jsx("button", { type: "button", onClick: () => handleNotificationClick(n), className: `w-full text-left px-3 py-2.5 hover:bg-accent transition-colors duration-150 ${n.is_read ? '' : 'bg-accent/40'}`, children: _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: `mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary transition-opacity duration-200 ${n.is_read ? 'opacity-0' : 'opacity-100'}`, "aria-hidden": true }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-sm font-medium leading-tight truncate", children: n.title }), n.body && (_jsx("div", { className: "text-xs text-muted-foreground line-clamp-2 mt-0.5", children: n.body })), _jsx("div", { className: "text-[10px] text-muted-foreground mt-1", children: timeAgo(n.created_at) })] })] }) }) }, n.id))) }));
|
|
118
|
+
})(), _jsx("div", { className: "border-t px-3 py-2 text-center", children: _jsx("button", { type: "button", onClick: goToAllNotifications, className: "text-xs text-primary hover:underline", children: t('notifications.viewAll', { defaultValue: 'View all notifications' }) }) })] }), _jsx(TabsContent, { value: "approvals", className: "m-0 max-h-80 overflow-auto", children: _jsx("div", { className: "px-3 py-6 text-center motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300", children: pendingApprovalsCount > 0 ? (_jsxs(_Fragment, { children: [_jsx(CheckSquare, { className: "mx-auto h-6 w-6 text-amber-500 motion-safe:animate-in motion-safe:zoom-in-50 motion-safe:duration-300" }), _jsx("div", { className: "mt-2 text-sm font-medium", children: t('notifications.approvalsPending', {
|
|
79
119
|
defaultValue: '{{count}} pending approvals',
|
|
80
120
|
count: pendingApprovalsCount,
|
|
81
121
|
}) }), _jsx(Button, { size: "sm", className: "mt-3", onClick: goToApprovals, children: t('notifications.viewApprovals', { defaultValue: 'View approvals' }) })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-sm text-muted-foreground", children: t('notifications.noPendingApprovals', {
|
|
82
122
|
defaultValue: 'No pending approvals',
|
|
83
|
-
}) }), _jsx("button", { type: "button", onClick: goToApprovals, className: "mt-2 text-xs text-primary hover:underline", children: t('notifications.openApprovalsInbox', { defaultValue: 'Open Approvals Inbox' }) })] })) }) }), _jsxs(TabsContent, { value: "activity", className: "m-0 max-h-80 overflow-auto", children: [activities.length === 0 ? (_jsx("div", { className: "px-3 py-8 text-sm text-muted-foreground text-center", children: t('layout.activityFeed.empty', { defaultValue: 'No recent activity' }) })) : (_jsx("ul", { className: "divide-y", children: activities.slice(0, 20).map((a) => (_jsxs("li", { className: "px-3 py-2.5", children: [_jsxs("div", { className: "text-sm leading-tight truncate", children: [_jsx("span", { className: "font-medium", children: a.user }), ' ', _jsx("span", { className: "text-muted-foreground", children: a.description })] }), _jsxs("div", { className: "text-[10px] text-muted-foreground mt-0.5", children: [timeAgo(a.timestamp), " \u00B7 ", a.objectName] })] }, a.id))) })), _jsx("div", { className: "border-t px-3 py-2 text-center", children: _jsx("button", { type: "button", onClick: goToAllActivity, className: "text-xs text-primary hover:underline", children: t('layout.activityFeed.viewAll', { defaultValue: 'View all activity' }) }) })] })] })] })] }));
|
|
123
|
+
}) }), _jsx("button", { type: "button", onClick: goToApprovals, className: "mt-2 text-xs text-primary hover:underline", children: t('notifications.openApprovalsInbox', { defaultValue: 'Open Approvals Inbox' }) })] })) }) }), _jsxs(TabsContent, { value: "activity", className: "m-0 max-h-80 overflow-auto", children: [activities.length === 0 ? (_jsx("div", { className: "px-3 py-8 text-sm text-muted-foreground text-center motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300", children: t('layout.activityFeed.empty', { defaultValue: 'No recent activity' }) })) : (_jsx("ul", { className: "divide-y", children: activities.slice(0, 20).map((a, idx) => (_jsxs("li", { className: "px-3 py-2.5 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200", style: { animationDelay: `${Math.min(idx, 6) * 20}ms` }, children: [_jsxs("div", { className: "text-sm leading-tight truncate", children: [_jsx("span", { className: "font-medium", children: a.user }), ' ', _jsx("span", { className: "text-muted-foreground", children: a.description })] }), _jsxs("div", { className: "text-[10px] text-muted-foreground mt-0.5", children: [timeAgo(a.timestamp), " \u00B7 ", a.objectName] })] }, a.id))) })), _jsx("div", { className: "border-t px-3 py-2 text-center", children: _jsx("button", { type: "button", onClick: goToAllActivity, className: "text-xs text-primary hover:underline", children: t('layout.activityFeed.viewAll', { defaultValue: 'View all activity' }) }) })] })] })] })] }));
|
|
84
124
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability primitives — Sentry integration.
|
|
3
|
+
*
|
|
4
|
+
* All exports are no-op safe when no DSN is configured. See sentry.ts for
|
|
5
|
+
* configuration via `VITE_SENTRY_*` envvars.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
export { initSentry, captureError, setSentryUser, getSentry } from './sentry';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability primitives — Sentry integration.
|
|
3
|
+
*
|
|
4
|
+
* All exports are no-op safe when no DSN is configured. See sentry.ts for
|
|
5
|
+
* configuration via `VITE_SENTRY_*` envvars.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
export { initSentry, captureError, setSentryUser, getSentry } from './sentry';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentry integration — opt-in via `VITE_SENTRY_DSN`.
|
|
3
|
+
*
|
|
4
|
+
* Design goals:
|
|
5
|
+
* - **Zero cost when disabled.** `@sentry/react` is dynamically imported only
|
|
6
|
+
* when a DSN is configured, so apps without Sentry pay zero bundle bytes.
|
|
7
|
+
* - **Graceful degradation.** If init fails (network, CSP, etc.) we log a
|
|
8
|
+
* warning and continue — the host app must still render.
|
|
9
|
+
* - **Sensible defaults.** 10% transaction sampling, no session replay,
|
|
10
|
+
* `release` + `environment` pulled from Vite envvars.
|
|
11
|
+
*
|
|
12
|
+
* Env vars consumed (all optional):
|
|
13
|
+
* - `VITE_SENTRY_DSN` — DSN; absent disables the integration entirely
|
|
14
|
+
* - `VITE_SENTRY_ENVIRONMENT` — defaults to `MODE` (production/development)
|
|
15
|
+
* - `VITE_SENTRY_RELEASE` — defaults to `VITE_APP_VERSION` or `unknown`
|
|
16
|
+
* - `VITE_SENTRY_TRACES_SAMPLE_RATE` — defaults to `0.1`
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
type SentryModule = typeof import('@sentry/react');
|
|
21
|
+
/**
|
|
22
|
+
* Returns the loaded Sentry module, or `null` if Sentry was never initialized
|
|
23
|
+
* (e.g. DSN missing). Callers must handle the null case.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getSentry(): SentryModule | null;
|
|
26
|
+
/**
|
|
27
|
+
* Initializes Sentry if `VITE_SENTRY_DSN` is configured. Safe to call multiple
|
|
28
|
+
* times — only the first invocation runs.
|
|
29
|
+
*
|
|
30
|
+
* @returns `true` if Sentry was initialized, `false` if disabled or failed.
|
|
31
|
+
*/
|
|
32
|
+
export declare function initSentry(): Promise<boolean>;
|
|
33
|
+
/**
|
|
34
|
+
* Reports an error to Sentry if initialized; otherwise no-op. Use this from
|
|
35
|
+
* ErrorBoundary or any catch block where you want best-effort reporting.
|
|
36
|
+
*/
|
|
37
|
+
export declare function captureError(error: unknown, context?: Record<string, unknown>): void;
|
|
38
|
+
/**
|
|
39
|
+
* Sets the active user context for subsequent events. Pass `null` on logout.
|
|
40
|
+
*/
|
|
41
|
+
export declare function setSentryUser(user: {
|
|
42
|
+
id?: string;
|
|
43
|
+
email?: string;
|
|
44
|
+
username?: string;
|
|
45
|
+
} | null): void;
|
|
46
|
+
export {};
|