@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/dist/chrome/CommandPalette.d.ts +6 -1
  3. package/dist/chrome/CommandPalette.js +40 -4
  4. package/dist/chrome/ConsoleToaster.js +8 -1
  5. package/dist/chrome/ErrorBoundary.js +3 -0
  6. package/dist/chrome/RouteFader.d.ts +32 -0
  7. package/dist/chrome/RouteFader.js +52 -0
  8. package/dist/chrome/index.d.ts +2 -0
  9. package/dist/chrome/index.js +2 -0
  10. package/dist/chrome/toast-helpers.d.ts +48 -0
  11. package/dist/chrome/toast-helpers.js +59 -0
  12. package/dist/console/AppContent.js +108 -2
  13. package/dist/console/home/HomePage.js +5 -4
  14. package/dist/console/marketplace/MarkdownText.d.ts +19 -0
  15. package/dist/console/marketplace/MarkdownText.js +141 -0
  16. package/dist/console/marketplace/MarketplaceInstalledPage.d.ts +17 -0
  17. package/dist/console/marketplace/MarketplaceInstalledPage.js +64 -0
  18. package/dist/console/marketplace/MarketplacePackagePage.d.ts +7 -0
  19. package/dist/console/marketplace/MarketplacePackagePage.js +200 -0
  20. package/dist/console/marketplace/MarketplacePage.d.ts +8 -0
  21. package/dist/console/marketplace/MarketplacePage.js +94 -0
  22. package/dist/console/marketplace/PackageIcon.d.ts +19 -0
  23. package/dist/console/marketplace/PackageIcon.js +17 -0
  24. package/dist/console/marketplace/marketplaceApi.d.ts +122 -0
  25. package/dist/console/marketplace/marketplaceApi.js +207 -0
  26. package/dist/index.d.ts +6 -6
  27. package/dist/index.js +6 -5
  28. package/dist/layout/AppHeader.js +16 -2
  29. package/dist/layout/AppSidebar.js +15 -11
  30. package/dist/layout/InboxPopover.js +43 -3
  31. package/dist/observability/index.d.ts +9 -0
  32. package/dist/observability/index.js +9 -0
  33. package/dist/observability/sentry.d.ts +46 -0
  34. package/dist/observability/sentry.js +120 -0
  35. package/dist/types.d.ts +0 -46
  36. package/dist/views/RecordDetailView.js +79 -15
  37. package/package.json +26 -25
  38. package/src/styles.css +49 -0
  39. package/dist/components/DashboardRenderer.d.ts +0 -8
  40. package/dist/components/DashboardRenderer.js +0 -16
  41. package/dist/components/FormRenderer.d.ts +0 -8
  42. package/dist/components/FormRenderer.js +0 -31
  43. package/dist/components/ObjectRenderer.d.ts +0 -8
  44. package/dist/components/ObjectRenderer.js +0 -74
  45. package/dist/components/PageRenderer.d.ts +0 -7
  46. package/dist/components/PageRenderer.js +0 -14
@@ -0,0 +1,59 @@
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
+ * Toast helpers — convention-encoding wrappers around `sonner` so
10
+ * destructive flows (delete, archive, bulk-update) consistently expose
11
+ * an Undo affordance.
12
+ *
13
+ * Why a helper instead of raw `toast.success(...)` at call sites:
14
+ * 1. Most "delete OK" toasts in the codebase do not offer Undo, even
15
+ * when the underlying API supports a reversal. The helper makes
16
+ * adding Undo a one-line change.
17
+ * 2. The label, duration, and aria semantics stay consistent across
18
+ * surfaces — recently-deleted records, archived comments, bulk
19
+ * operations — so users don't have to relearn the affordance per
20
+ * view.
21
+ *
22
+ * Usage:
23
+ *
24
+ * import { toastWithUndo } from '@object-ui/app-shell';
25
+ *
26
+ * toastWithUndo('Lead deleted', {
27
+ * onUndo: () => dataSource.restore('lead', id),
28
+ * });
29
+ */
30
+ import { toast } from 'sonner';
31
+ export function toastWithUndo(message, opts) {
32
+ const { onUndo, undoLabel = 'Undo', intent = 'success', duration = 6000, action: _ignoredAction, ...rest } = opts;
33
+ const emitter = intent === 'warning'
34
+ ? toast.warning
35
+ : intent === 'info'
36
+ ? toast.info
37
+ : toast.success;
38
+ return emitter(message, {
39
+ duration,
40
+ action: {
41
+ label: undoLabel,
42
+ onClick: async () => {
43
+ try {
44
+ const result = onUndo();
45
+ if (result && typeof result.then === 'function') {
46
+ await result;
47
+ }
48
+ toast.success('Action undone');
49
+ }
50
+ catch (err) {
51
+ toast.error('Could not undo', {
52
+ description: err instanceof Error ? err.message : undefined,
53
+ });
54
+ }
55
+ },
56
+ },
57
+ ...rest,
58
+ });
59
+ }
@@ -30,6 +30,7 @@ import { LoadingScreen } from '../chrome/LoadingScreen';
30
30
  import { ObjectView } from '../views/ObjectView';
31
31
  import { KeyboardShortcutsDialog } from '../chrome/KeyboardShortcutsDialog';
32
32
  import { OnboardingWalkthrough } from '../chrome/OnboardingWalkthrough';
33
+ import { RouteFader } from '../chrome/RouteFader';
33
34
  import { NavigationSyncEffect } from '../hooks/useNavigationSync';
34
35
  // Route-based code splitting — lazy-load less-frequently-used routes
35
36
  const RecordDetailView = lazy(() => import('../views/RecordDetailView').then(m => ({ default: m.RecordDetailView })));
@@ -44,6 +45,11 @@ const CreateAppPage = lazy(() => import('@object-ui/plugin-designer').then(m =>
44
45
  const EditAppPage = lazy(() => import('@object-ui/plugin-designer').then(m => ({ default: m.EditAppPage })));
45
46
  const PageDesignPage = lazy(() => import('@object-ui/plugin-designer').then(m => ({ default: m.PageDesignPage })));
46
47
  const DashboardDesignPage = lazy(() => import('@object-ui/plugin-designer').then(m => ({ default: m.DashboardDesignPage })));
48
+ // Marketplace pages — first-class platform feature; mounted at `system/marketplace`
49
+ // under any active app so admins can browse + install from inside the runtime.
50
+ const MarketplacePage = lazy(() => import('./marketplace/MarketplacePage').then(m => ({ default: m.MarketplacePage })));
51
+ const MarketplacePackagePage = lazy(() => import('./marketplace/MarketplacePackagePage').then(m => ({ default: m.MarketplacePackagePage })));
52
+ const MarketplaceInstalledPage = lazy(() => import('./marketplace/MarketplaceInstalledPage').then(m => ({ default: m.MarketplaceInstalledPage })));
47
53
  export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
48
54
  const [connectionState, setConnectionState] = useState('disconnected');
49
55
  const { user } = useAuth();
@@ -226,12 +232,12 @@ export function AppContent({ extraRoutes, extraRoutesNoApp } = {}) {
226
232
  if (!activeApp && !isCreateAppRoute && !isSystemRoute)
227
233
  return (_jsx("div", { className: "h-screen flex items-center justify-center", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('empty.noAppsConfigured') }), _jsx(EmptyDescription, { children: t('empty.noAppsConfiguredDescription') }), _jsxs("div", { className: "mt-4 flex flex-col sm:flex-row items-center gap-3", children: [_jsx(Button, { onClick: () => navigate('/create-app'), "data-testid": "create-first-app-btn", children: t('empty.createFirstApp') }), _jsx(Button, { variant: "outline", onClick: () => navigate('/apps/setup'), "data-testid": "go-to-settings-btn", children: t('empty.systemSettings') })] })] }) }));
228
234
  if (!activeApp && (isCreateAppRoute || isSystemRoute)) {
229
- return (_jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsxs(Routes, { children: [_jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), extraRoutesNoApp] }) }));
235
+ return (_jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsxs(Routes, { children: [_jsx(Route, { path: "create-app", element: _jsx(CreateAppPage, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutesNoApp] }) }));
230
236
  }
231
237
  const expressionUser = user
232
238
  ? { name: user.name, email: user.email, role: user.role ?? 'user' }
233
239
  : { name: 'Anonymous', email: '', role: 'guest' };
234
- 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: {
240
+ 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, dataSource: dataSource }), _jsx(KeyboardShortcutsDialog, {}), _jsx(OnboardingWalkthrough, {}), _jsx(ErrorBoundary, { children: _jsx(Suspense, { fallback: _jsx(LoadingScreen, {}), children: _jsx(RouteFader, { children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Navigate, { to: resolveLandingRoute(activeApp), 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, {}) }), _jsx(Route, { path: "system/marketplace", element: _jsx(MarketplacePage, {}) }), _jsx(Route, { path: "system/marketplace/installed", element: _jsx(MarketplaceInstalledPage, {}) }), _jsx(Route, { path: "system/marketplace/:packageId", element: _jsx(MarketplacePackagePage, {}) }), extraRoutes, _jsx(Route, { path: ":objectName/:maybeRecordId", element: _jsx(ShorthandRecordRedirect, {}) }), _jsx(Route, { path: "*", element: _jsx(RouteNotFound, {}) })] }) }) }) }), currentObjectDef && (_jsx(ModalForm, { schema: {
235
241
  type: 'object-form',
236
242
  formType: 'modal',
237
243
  objectName: currentObjectDef.name,
@@ -289,3 +295,103 @@ function findFirstRoute(items) {
289
295
  }
290
296
  return '';
291
297
  }
298
+ // Build the per-item route segment without recursing through groups —
299
+ // used when `homePageId` resolved to an exact match and we just need to
300
+ // know how to address it.
301
+ function buildItemRoute(item) {
302
+ if (!item)
303
+ return '';
304
+ if (item.type === 'object')
305
+ return item.viewName ? `${item.objectName}/view/${item.viewName}` : `${item.objectName}`;
306
+ if (item.type === 'page')
307
+ return item.pageName ? `page/${item.pageName}` : '';
308
+ if (item.type === 'dashboard')
309
+ return item.dashboardName ? `dashboard/${item.dashboardName}` : '';
310
+ if (item.type === 'report')
311
+ return item.reportName ? `report/${item.reportName}` : '';
312
+ return '';
313
+ }
314
+ function findNavItemById(items, id) {
315
+ if (!items)
316
+ return undefined;
317
+ for (const item of items) {
318
+ if (item.id === id)
319
+ return item;
320
+ if (item.type === 'group' && item.children) {
321
+ const hit = findNavItemById(item.children, id);
322
+ if (hit)
323
+ return hit;
324
+ }
325
+ }
326
+ return undefined;
327
+ }
328
+ /**
329
+ * Resolves the route to navigate to when the user lands on the bare
330
+ * `/console/apps/:appName` URL. Honors the app's explicit
331
+ * `homePageId` (Salesforce-style "Default Landing"); falls back to the
332
+ * first reachable nav item only when no homePageId is set or it points
333
+ * at something that doesn't yield a route. This is what lets the CRM
334
+ * example open on the Sales Dashboard instead of the Lead list.
335
+ */
336
+ function resolveLandingRoute(activeApp) {
337
+ const homePageId = activeApp?.homePageId;
338
+ const navigation = activeApp?.navigation || [];
339
+ if (homePageId) {
340
+ const item = findNavItemById(navigation, homePageId);
341
+ const route = buildItemRoute(item);
342
+ if (route)
343
+ return route;
344
+ }
345
+ return findFirstRoute(navigation);
346
+ }
347
+ /**
348
+ * Heuristic: distinguish a record id from a route fragment.
349
+ *
350
+ * Record ids in this system are URL-safe slugs (alnum + `_` / `-`), typically
351
+ * 8+ chars (often 16+). They never collide with reserved second-segment
352
+ * keywords used by the route table (`new`, `view`, `record`, `dashboard`,
353
+ * `report`, `page`, `design`, `search`, `create-app`, `edit-app`).
354
+ */
355
+ const RESERVED_SECOND_SEGMENTS = new Set([
356
+ 'new', 'view', 'record', 'edit',
357
+ 'dashboard', 'report', 'page',
358
+ 'design', 'search', 'create-app', 'edit-app',
359
+ ]);
360
+ function looksLikeRecordId(segment) {
361
+ if (!segment)
362
+ return false;
363
+ if (RESERVED_SECOND_SEGMENTS.has(segment))
364
+ return false;
365
+ // Allow URL-safe slug chars; reject anything with `/`, `?`, `#`, spaces.
366
+ if (!/^[A-Za-z0-9_-]+$/.test(segment))
367
+ return false;
368
+ // Most record ids are at least 6 chars (UUID, ULID, nanoid all >=8).
369
+ return segment.length >= 6;
370
+ }
371
+ /**
372
+ * Redirects `/apps/:appName/:objectName/:recordId` shorthand to the
373
+ * canonical `/apps/:appName/:objectName/record/:recordId` so externally
374
+ * shared / pasted links work, and legacy URL producers that built the
375
+ * shorthand keep functioning.
376
+ */
377
+ function ShorthandRecordRedirect() {
378
+ const { objectName, maybeRecordId } = useParams();
379
+ const location = useLocation();
380
+ if (objectName && looksLikeRecordId(maybeRecordId)) {
381
+ const target = `${location.pathname.replace(/\/$/, '').replace(`/${maybeRecordId}`, `/record/${maybeRecordId}`)}${location.search}${location.hash}`;
382
+ return _jsx(Navigate, { to: target, replace: true });
383
+ }
384
+ return _jsx(RouteNotFound, {});
385
+ }
386
+ /**
387
+ * Visible "not found" fallback rendered for any unmatched URL inside the
388
+ * console app shell. Previously these URLs produced a fully blank content
389
+ * area with no indication that anything had gone wrong — users would think
390
+ * the app had crashed. An explicit Empty state with a "back to app home"
391
+ * action turns an opaque failure into a recoverable one.
392
+ */
393
+ function RouteNotFound() {
394
+ const navigate = useNavigate();
395
+ const { t } = useObjectTranslation();
396
+ return (_jsx("div", { className: "flex h-full w-full items-center justify-center p-8", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('console.notFound.title', { defaultValue: 'Page not found' }) }), _jsx(EmptyDescription, { children: t('console.notFound.description', { defaultValue: 'The URL you followed does not match any view in this app.' }) }), _jsx("div", { className: "mt-4", children: _jsx(Button, { variant: "outline", onClick: () => navigate(-1), children: t('console.notFound.back', { defaultValue: 'Go back' }) }) })] }) }));
397
+ }
@@ -22,12 +22,12 @@ import { useMetadata } from '../../providers/MetadataProvider';
22
22
  import { useRecentItems } from '../../hooks/useRecentItems';
23
23
  import { useFavorites } from '../../hooks/useFavorites';
24
24
  import { useObjectTranslation } from '@object-ui/i18n';
25
- import { useAuth } from '@object-ui/auth';
25
+ import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth';
26
26
  import { AppCard } from './AppCard';
27
27
  import { RecentApps } from './RecentApps';
28
28
  import { StarredApps } from './StarredApps';
29
29
  import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
30
- import { Plus, Settings, Sparkles, Star, Clock, ArrowDown } from 'lucide-react';
30
+ import { Plus, Settings, Sparkles, Star, Clock, ArrowDown, Store } from 'lucide-react';
31
31
  function pickGreetingKey(hour) {
32
32
  if (hour < 5)
33
33
  return 'home.greetingNight';
@@ -56,6 +56,7 @@ export function HomePage() {
56
56
  const { recentItems } = useRecentItems();
57
57
  const { favorites } = useFavorites();
58
58
  const { user } = useAuth();
59
+ const isAdmin = useIsWorkspaceAdmin();
59
60
  const activeApps = apps.filter((a) => a.active !== false);
60
61
  const recentApps = recentItems
61
62
  .filter(item => item.type === 'object' || item.type === 'dashboard' || item.type === 'page' || item.type === 'record')
@@ -71,7 +72,7 @@ export function HomePage() {
71
72
  if (activeApps.length === 0) {
72
73
  return (_jsx("div", { className: "flex flex-1 items-center justify-center p-6", children: _jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('home.welcome', { defaultValue: 'Welcome to ObjectUI' }) }), _jsx(EmptyDescription, { children: t('home.welcomeDescription', {
73
74
  defaultValue: 'Get started by creating your first application or configure your system settings.',
74
- }) }), _jsxs("div", { className: "mt-6 flex flex-col sm:flex-row items-center gap-3", children: [_jsxs(Button, { onClick: () => navigate('/create-app'), "data-testid": "create-first-app-btn", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('home.createFirstApp', { defaultValue: 'Create Your First App' })] }), _jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup'), "data-testid": "go-to-settings-btn", children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('home.systemSettings', { defaultValue: 'System Settings' })] })] })] }) }));
75
+ }) }), _jsxs("div", { className: "mt-6 flex flex-col sm:flex-row items-center gap-3", children: [_jsxs(Button, { onClick: () => navigate('/create-app'), "data-testid": "create-first-app-btn", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('home.createFirstApp', { defaultValue: 'Create Your First App' })] }), isAdmin && (_jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup/system/marketplace'), "data-testid": "browse-marketplace-empty-btn", children: [_jsx(Store, { className: "mr-2 h-4 w-4" }), t('home.browseMarketplace', { defaultValue: 'Browse App Marketplace' })] })), _jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup'), "data-testid": "go-to-settings-btn", children: [_jsx(Settings, { className: "mr-2 h-4 w-4" }), t('home.systemSettings', { defaultValue: 'System Settings' })] })] })] }) }));
75
76
  }
76
- return (_jsxs("div", { className: "relative isolate min-h-full bg-gradient-to-b from-background via-background to-muted/40", children: [_jsxs("div", { "aria-hidden": true, className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[28rem] overflow-hidden", children: [_jsx("div", { className: "absolute -top-32 -left-24 h-[28rem] w-[28rem] rounded-full bg-primary/30 blur-3xl opacity-70 dark:opacity-40" }), _jsx("div", { className: "absolute -top-20 right-[-6rem] h-[26rem] w-[36rem] rounded-full bg-sky-400/30 blur-3xl opacity-70 dark:opacity-35" }), _jsx("div", { className: "absolute top-32 left-1/3 h-[18rem] w-[24rem] rounded-full bg-fuchsia-400/25 blur-3xl opacity-60 dark:opacity-25" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-b from-transparent via-background/40 to-background" })] }), _jsx("section", { className: "px-4 sm:px-6 lg:px-8 pt-10 pb-6", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsxs("div", { className: "flex items-center gap-2 text-xs font-medium text-muted-foreground mb-3", children: [_jsx(Sparkles, { className: "h-3.5 w-3.5 text-primary" }), _jsx("span", { className: "uppercase tracking-wider", children: t('home.title', { defaultValue: 'Home' }) })] }), _jsxs("h1", { className: "text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-pretty", children: [_jsxs("span", { className: "bg-gradient-to-r from-foreground via-foreground to-foreground/70 bg-clip-text text-transparent", children: [greeting, displayName ? `, ${displayName}` : ''] }), _jsx("span", { className: "text-foreground/40", children: "." })] }), _jsx("p", { className: "text-base sm:text-lg text-muted-foreground mt-2 max-w-2xl", children: t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' }) })] }) }), _jsx("div", { className: "px-4 sm:px-6 lg:px-8 pb-16", children: _jsxs("div", { className: "max-w-7xl mx-auto space-y-10", children: [starredApps.length === 0 && recentApps.length === 0 && (_jsx(GettingStartedHint, { t: t })), starredApps.length > 0 && _jsx(StarredApps, { items: starredApps }), recentApps.length > 0 && _jsx(RecentApps, { items: recentApps }), _jsxs("section", { children: [_jsx("div", { className: "flex items-end justify-between mb-5", children: _jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsxs("p", { className: "text-sm text-muted-foreground mt-1", children: [activeApps.length, ' · ', t('home.stats.apps', { defaultValue: 'Applications' })] })] }) }), _jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app, idx) => (_jsx(AppCard, { app: app, index: idx, onClick: () => navigate(`/apps/${app.name}`), isFavorite: favorites.some(f => f.id === `app:${app.name}`) }, app.name))) })] })] }) })] }));
77
+ return (_jsxs("div", { className: "relative isolate min-h-full bg-gradient-to-b from-background via-background to-muted/40", children: [_jsxs("div", { "aria-hidden": true, className: "pointer-events-none absolute inset-x-0 top-0 -z-10 h-[28rem] overflow-hidden", children: [_jsx("div", { className: "absolute -top-32 -left-24 h-[28rem] w-[28rem] rounded-full bg-primary/30 blur-3xl opacity-70 dark:opacity-40" }), _jsx("div", { className: "absolute -top-20 right-[-6rem] h-[26rem] w-[36rem] rounded-full bg-sky-400/30 blur-3xl opacity-70 dark:opacity-35" }), _jsx("div", { className: "absolute top-32 left-1/3 h-[18rem] w-[24rem] rounded-full bg-fuchsia-400/25 blur-3xl opacity-60 dark:opacity-25" }), _jsx("div", { className: "absolute inset-0 bg-gradient-to-b from-transparent via-background/40 to-background" })] }), _jsx("section", { className: "px-4 sm:px-6 lg:px-8 pt-10 pb-6", children: _jsxs("div", { className: "max-w-7xl mx-auto", children: [_jsxs("div", { className: "flex items-center gap-2 text-xs font-medium text-muted-foreground mb-3", children: [_jsx(Sparkles, { className: "h-3.5 w-3.5 text-primary" }), _jsx("span", { className: "uppercase tracking-wider", children: t('home.title', { defaultValue: 'Home' }) })] }), _jsxs("h1", { className: "text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-pretty", children: [_jsxs("span", { className: "bg-gradient-to-r from-foreground via-foreground to-foreground/70 bg-clip-text text-transparent", children: [greeting, displayName ? `, ${displayName}` : ''] }), _jsx("span", { className: "text-foreground/40", children: "." })] }), _jsx("p", { className: "text-base sm:text-lg text-muted-foreground mt-2 max-w-2xl", children: t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' }) })] }) }), _jsx("div", { className: "px-4 sm:px-6 lg:px-8 pb-16", children: _jsxs("div", { className: "max-w-7xl mx-auto space-y-10", children: [starredApps.length === 0 && recentApps.length === 0 && (_jsx(GettingStartedHint, { t: t })), starredApps.length > 0 && _jsx(StarredApps, { items: starredApps }), recentApps.length > 0 && _jsx(RecentApps, { items: recentApps }), _jsxs("section", { children: [_jsxs("div", { className: "flex items-end justify-between mb-5", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-2xl font-semibold tracking-tight", children: t('home.allApps', { defaultValue: 'All Applications' }) }), _jsxs("p", { className: "text-sm text-muted-foreground mt-1", children: [activeApps.length, ' · ', t('home.stats.apps', { defaultValue: 'Applications' })] })] }), isAdmin && (_jsxs(Button, { variant: "outline", onClick: () => navigate('/apps/setup/system/marketplace'), "data-testid": "browse-marketplace-btn", children: [_jsx(Store, { className: "mr-2 h-4 w-4" }), t('home.browseMarketplace', { defaultValue: 'Browse App Marketplace' })] }))] }), _jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4", children: activeApps.map((app, idx) => (_jsx(AppCard, { app: app, index: idx, onClick: () => navigate(`/apps/${app.name}`), isFavorite: favorites.some(f => f.id === `app:${app.name}`) }, app.name))) })] })] }) })] }));
77
78
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Tiny CommonMark-subset renderer for marketplace READMEs.
3
+ *
4
+ * Covers what app authors actually write in their README field:
5
+ * • `#`, `##`, `###` headings
6
+ * • Blank-line-separated paragraphs
7
+ * • `- ` / `* ` bulleted lists (single level)
8
+ * • `1. ` numbered lists (single level)
9
+ * • `**bold**`, `*italic*`, `` `code` ``, `[text](url)` inline
10
+ * • Triple-backtick fenced code blocks
11
+ *
12
+ * Deliberately NOT a full Markdown engine — we want zero new deps and
13
+ * predictable output. If a README really needs tables or images, the
14
+ * publisher can host their own docs and link via `homepage_url`.
15
+ */
16
+ export declare function MarkdownText({ source, className }: {
17
+ source: string;
18
+ className?: string;
19
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,141 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Tiny CommonMark-subset renderer for marketplace READMEs.
4
+ *
5
+ * Covers what app authors actually write in their README field:
6
+ * • `#`, `##`, `###` headings
7
+ * • Blank-line-separated paragraphs
8
+ * • `- ` / `* ` bulleted lists (single level)
9
+ * • `1. ` numbered lists (single level)
10
+ * • `**bold**`, `*italic*`, `` `code` ``, `[text](url)` inline
11
+ * • Triple-backtick fenced code blocks
12
+ *
13
+ * Deliberately NOT a full Markdown engine — we want zero new deps and
14
+ * predictable output. If a README really needs tables or images, the
15
+ * publisher can host their own docs and link via `homepage_url`.
16
+ */
17
+ import { Fragment } from 'react';
18
+ function parseBlocks(src) {
19
+ const lines = src.replace(/\r\n/g, '\n').split('\n');
20
+ const blocks = [];
21
+ let i = 0;
22
+ while (i < lines.length) {
23
+ const line = lines[i];
24
+ // Fenced code block
25
+ if (line.startsWith('```')) {
26
+ const lang = line.slice(3).trim();
27
+ i++;
28
+ const buf = [];
29
+ while (i < lines.length && !lines[i].startsWith('```')) {
30
+ buf.push(lines[i]);
31
+ i++;
32
+ }
33
+ i++; // closing fence
34
+ blocks.push({ type: 'code', text: buf.join('\n'), lang });
35
+ continue;
36
+ }
37
+ // Headings
38
+ const h = /^(#{1,3})\s+(.*)$/.exec(line);
39
+ if (h) {
40
+ const level = h[1].length;
41
+ blocks.push({ type: `h${level}`, text: h[2].trim() });
42
+ i++;
43
+ continue;
44
+ }
45
+ // Bullet list
46
+ if (/^[-*]\s+/.test(line)) {
47
+ const items = [];
48
+ while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
49
+ items.push(lines[i].replace(/^[-*]\s+/, ''));
50
+ i++;
51
+ }
52
+ blocks.push({ type: 'ul', items });
53
+ continue;
54
+ }
55
+ // Numbered list
56
+ if (/^\d+\.\s+/.test(line)) {
57
+ const items = [];
58
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
59
+ items.push(lines[i].replace(/^\d+\.\s+/, ''));
60
+ i++;
61
+ }
62
+ blocks.push({ type: 'ol', items });
63
+ continue;
64
+ }
65
+ // Blank line → flush
66
+ if (!line.trim()) {
67
+ i++;
68
+ continue;
69
+ }
70
+ // Paragraph (collect until blank / heading / list)
71
+ const buf = [];
72
+ while (i < lines.length &&
73
+ lines[i].trim() &&
74
+ !/^#{1,3}\s+/.test(lines[i]) &&
75
+ !/^[-*]\s+/.test(lines[i]) &&
76
+ !/^\d+\.\s+/.test(lines[i]) &&
77
+ !lines[i].startsWith('```')) {
78
+ buf.push(lines[i]);
79
+ i++;
80
+ }
81
+ blocks.push({ type: 'p', text: buf.join(' ') });
82
+ }
83
+ return blocks;
84
+ }
85
+ // Inline pass: **bold**, *italic*, `code`, [text](url). Order matters
86
+ // so we tokenize once and replace by position.
87
+ function renderInline(input) {
88
+ const out = [];
89
+ let remaining = input;
90
+ let key = 0;
91
+ const patterns = [
92
+ { re: /`([^`]+)`/, render: (m) => _jsx("code", { className: "rounded bg-muted px-1.5 py-0.5 font-mono text-[0.85em]", children: m[1] }, `k${key++}`) },
93
+ { re: /\*\*([^*]+)\*\*/, render: (m) => _jsx("strong", { className: "font-semibold text-foreground", children: m[1] }, `k${key++}`) },
94
+ { re: /\*([^*]+)\*/, render: (m) => _jsx("em", { children: m[1] }, `k${key++}`) },
95
+ { re: /\[([^\]]+)\]\(([^)]+)\)/, render: (m) => (_jsx("a", { href: m[2], target: "_blank", rel: "noopener noreferrer", className: "text-violet-600 underline-offset-2 hover:underline dark:text-violet-400", children: m[1] }, `k${key++}`)) },
96
+ ];
97
+ while (remaining) {
98
+ let bestIdx = -1;
99
+ let bestMatch = null;
100
+ let bestPattern = null;
101
+ for (const p of patterns) {
102
+ const m = p.re.exec(remaining);
103
+ if (m && (bestIdx === -1 || m.index < bestIdx)) {
104
+ bestIdx = m.index;
105
+ bestMatch = m;
106
+ bestPattern = p;
107
+ }
108
+ }
109
+ if (!bestMatch || !bestPattern) {
110
+ out.push(remaining);
111
+ break;
112
+ }
113
+ if (bestIdx > 0)
114
+ out.push(remaining.slice(0, bestIdx));
115
+ out.push(bestPattern.render(bestMatch));
116
+ remaining = remaining.slice(bestIdx + bestMatch[0].length);
117
+ }
118
+ return _jsx(_Fragment, { children: out.map((n, i) => _jsx(Fragment, { children: n }, i)) });
119
+ }
120
+ export function MarkdownText({ source, className }) {
121
+ const blocks = parseBlocks(source);
122
+ return (_jsx("div", { className: `flex flex-col gap-4 text-sm leading-relaxed text-foreground/90 ${className ?? ''}`, children: blocks.map((b, i) => {
123
+ switch (b.type) {
124
+ case 'h1':
125
+ return _jsx("h2", { className: "mt-2 text-xl font-bold tracking-tight", children: renderInline(b.text ?? '') }, i);
126
+ case 'h2':
127
+ return _jsx("h3", { className: "mt-3 text-base font-semibold tracking-tight", children: renderInline(b.text ?? '') }, i);
128
+ case 'h3':
129
+ return _jsx("h4", { className: "mt-2 text-sm font-semibold tracking-tight text-foreground/80", children: renderInline(b.text ?? '') }, i);
130
+ case 'p':
131
+ return _jsx("p", { children: renderInline(b.text ?? '') }, i);
132
+ case 'ul':
133
+ return (_jsx("ul", { className: "ml-1 flex list-none flex-col gap-1.5", children: (b.items ?? []).map((it, j) => (_jsxs("li", { className: "flex gap-2", children: [_jsx("span", { "aria-hidden": "true", className: "mt-1.5 inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-violet-500/70" }), _jsx("span", { children: renderInline(it) })] }, j))) }, i));
134
+ case 'ol':
135
+ return (_jsx("ol", { className: "ml-5 flex list-decimal flex-col gap-1.5 marker:text-muted-foreground", children: (b.items ?? []).map((it, j) => (_jsx("li", { className: "pl-1", children: renderInline(it) }, j))) }, i));
136
+ case 'code':
137
+ return (_jsx("pre", { className: "overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs leading-relaxed", children: _jsx("code", { className: "font-mono", children: b.text }) }, i));
138
+ }
139
+ return null;
140
+ }) }));
141
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Marketplace Installed Apps Page.
3
+ *
4
+ * Lists packages currently installed into THIS runtime's kernel (the
5
+ * `install-local` path, not cloud environments). Each row has an
6
+ * Uninstall action that removes the cached manifest from
7
+ * `<cwd>/.objectstack/installed-packages/<id>.json`.
8
+ *
9
+ * Note the explicit caveat: the framework kernel API is additive only
10
+ * (`engine.registerApp(manifest)`) — there is no `unregisterApp`. So
11
+ * uninstall removes the on-disk cache (preventing the package from
12
+ * re-loading on next boot) but the app remains live in the running
13
+ * kernel until the runtime is restarted. We show this in the UI so a
14
+ * developer doesn't get confused when the app stays visible in the
15
+ * switcher after clicking Uninstall.
16
+ */
17
+ export declare function MarketplaceInstalledPage(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Marketplace Installed Apps Page.
4
+ *
5
+ * Lists packages currently installed into THIS runtime's kernel (the
6
+ * `install-local` path, not cloud environments). Each row has an
7
+ * Uninstall action that removes the cached manifest from
8
+ * `<cwd>/.objectstack/installed-packages/<id>.json`.
9
+ *
10
+ * Note the explicit caveat: the framework kernel API is additive only
11
+ * (`engine.registerApp(manifest)`) — there is no `unregisterApp`. So
12
+ * uninstall removes the on-disk cache (preventing the package from
13
+ * re-loading on next boot) but the app remains live in the running
14
+ * kernel until the runtime is restarted. We show this in the UI so a
15
+ * developer doesn't get confused when the app stays visible in the
16
+ * switcher after clicking Uninstall.
17
+ */
18
+ import { useEffect, useState } from 'react';
19
+ import { useNavigate, useParams } from 'react-router-dom';
20
+ import { Card, CardContent, CardHeader, CardTitle, Badge, Button, Skeleton, } from '@object-ui/components';
21
+ import { ArrowLeft, RefreshCcw, Store, Trash2, AlertCircle, ExternalLink } from 'lucide-react';
22
+ import { listLocalInstalls, uninstallLocal, } from './marketplaceApi';
23
+ export function MarketplaceInstalledPage() {
24
+ const navigate = useNavigate();
25
+ const { appName } = useParams();
26
+ const basePath = appName ? `/apps/${appName}` : '';
27
+ const [items, setItems] = useState([]);
28
+ const [loading, setLoading] = useState(true);
29
+ const [working, setWorking] = useState(null);
30
+ const [result, setResult] = useState(null);
31
+ const load = async () => {
32
+ setLoading(true);
33
+ try {
34
+ const list = await listLocalInstalls();
35
+ setItems(list);
36
+ }
37
+ finally {
38
+ setLoading(false);
39
+ }
40
+ };
41
+ useEffect(() => { void load(); }, []);
42
+ const doUninstall = async (entry) => {
43
+ if (!confirm(`Uninstall ${entry.manifestId} v${entry.version} from this runtime?\n\nThe cached manifest will be removed. The app will remain loaded in the running kernel until the next restart.`)) {
44
+ return;
45
+ }
46
+ setWorking(entry.manifestId);
47
+ setResult(null);
48
+ try {
49
+ await uninstallLocal(entry.manifestId);
50
+ setResult({
51
+ ok: true,
52
+ message: `Removed ${entry.manifestId}. Restart the runtime to fully unload it from the running kernel.`,
53
+ });
54
+ await load();
55
+ }
56
+ catch (e) {
57
+ setResult({ ok: false, message: e?.message ?? String(e) });
58
+ }
59
+ finally {
60
+ setWorking(null);
61
+ }
62
+ };
63
+ return (_jsxs("div", { className: "flex flex-col gap-6 p-4 sm:p-6 max-w-5xl", children: [_jsxs(Button, { variant: "ghost", size: "sm", className: "self-start", onClick: () => navigate(`${basePath}/system/marketplace`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Back to marketplace"] }), _jsxs("div", { className: "flex items-start justify-between gap-4 flex-wrap", children: [_jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Store, { className: "h-6 w-6 text-primary", "aria-hidden": "true" }), _jsx("h1", { className: "text-xl sm:text-2xl font-bold tracking-tight", children: "Installed Apps" })] }), _jsxs("p", { className: "text-sm text-muted-foreground mt-1", children: ["Marketplace packages currently installed into this runtime's kernel. Cached manifests live in ", _jsx("code", { className: "font-mono text-xs", children: ".objectstack/installed-packages/" }), " and survive restarts."] })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => void load(), disabled: loading, children: [_jsx(RefreshCcw, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Refresh"] })] }), result && (_jsx("div", { className: `rounded-md border p-3 text-sm ${result.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`, children: _jsxs("div", { className: "flex items-start gap-2", children: [_jsx(AlertCircle, { className: "h-4 w-4 mt-0.5 shrink-0", "aria-hidden": "true" }), _jsx("div", { children: result.message })] }) })), loading && items.length === 0 ? (_jsx("div", { className: "grid gap-3", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Skeleton, { className: "h-20 w-full" }, i))) })) : items.length === 0 ? (_jsxs("div", { className: "text-center py-12 text-sm text-muted-foreground border rounded-md", children: [_jsx("p", { children: "No marketplace apps installed in this runtime yet." }), _jsx(Button, { variant: "link", className: "mt-2", onClick: () => navigate(`${basePath}/system/marketplace`), children: "Browse the marketplace \u2192" })] })) : (_jsx("div", { className: "grid gap-3", children: items.map((entry) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-start justify-between gap-3 pb-2", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs(CardTitle, { className: "text-base truncate flex items-center gap-2", children: [entry.manifestId, _jsxs(Badge, { variant: "outline", children: ["v", entry.version] })] }), _jsxs("div", { className: "text-xs text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1", children: [_jsxs("span", { children: ["Installed ", new Date(entry.installedAt).toLocaleString()] }), entry.installedBy && _jsxs("span", { children: ["by ", entry.installedBy] }), _jsxs("span", { children: ["package ", _jsx("code", { className: "font-mono", children: entry.packageId })] })] })] }), _jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate(`${basePath}/system/marketplace/${entry.packageId}`), children: [_jsx(ExternalLink, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Details"] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => void doUninstall(entry), disabled: working === entry.manifestId, children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), working === entry.manifestId ? 'Uninstalling…' : 'Uninstall'] })] })] }), _jsxs(CardContent, { className: "pt-0 text-xs text-muted-foreground", children: ["Cached as ", _jsxs("code", { className: "font-mono", children: [".objectstack/installed-packages/", entry.manifestId.replace(/[^a-zA-Z0-9._-]/g, '_'), ".json"] })] })] }, entry.manifestId))) })), _jsxs("p", { className: "text-xs text-muted-foreground border-t pt-4", children: [_jsx("strong", { children: "Note:" }), " The kernel API is additive only \u2014 uninstall removes the on-disk manifest so the package won't load on next boot, but the running kernel keeps the app registered until you restart the runtime."] })] }));
64
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Marketplace Package Detail Page.
3
+ *
4
+ * Shows full package metadata + readme + approved version list. Provides
5
+ * an "Install" button that opens a dialog with environment picker.
6
+ */
7
+ export declare function MarketplacePackagePage(): import("react/jsx-runtime").JSX.Element;