@object-ui/app-shell 5.1.1 → 5.2.1
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 +273 -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/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/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 +199 -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 +5 -6
- package/dist/index.js +4 -5
- package/dist/layout/AppHeader.js +16 -2
- package/dist/layout/AppSidebar.js +15 -11
- package/dist/layout/InboxPopover.js +43 -3
- package/dist/types.d.ts +0 -46
- package/dist/views/RecordDetailView.js +79 -15
- package/package.json +25 -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
|
@@ -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:
|
|
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: [
|
|
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,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;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Marketplace Package Detail Page.
|
|
4
|
+
*
|
|
5
|
+
* Shows full package metadata + readme + approved version list. Provides
|
|
6
|
+
* an "Install" button that opens a dialog with environment picker.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from 'react';
|
|
9
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
10
|
+
import { Button, Badge, Card, CardContent, CardHeader, CardTitle, Skeleton, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Label, Checkbox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@object-ui/components';
|
|
11
|
+
import { ArrowLeft, ExternalLink, Download, AlertCircle, Package, Trash2 } from 'lucide-react';
|
|
12
|
+
import { PackageIcon } from './PackageIcon';
|
|
13
|
+
import { getMarketplacePackage, installPackage, installLocal, uninstallLocal, listLocalInstalls, listCloudEnvironments, listInstallableOrgIds, cloudInstallDeepLink, } from './marketplaceApi';
|
|
14
|
+
export function MarketplacePackagePage() {
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
const { packageId, appName } = useParams();
|
|
17
|
+
const basePath = appName ? `/apps/${appName}` : '';
|
|
18
|
+
const [data, setData] = useState(null);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
const [error, setError] = useState(null);
|
|
21
|
+
const [installOpen, setInstallOpen] = useState(false);
|
|
22
|
+
const [envs, setEnvs] = useState([]);
|
|
23
|
+
const [envsLoading, setEnvsLoading] = useState(false);
|
|
24
|
+
const [envsError, setEnvsError] = useState(null);
|
|
25
|
+
const [selectedEnv, setSelectedEnv] = useState('');
|
|
26
|
+
const [seedSampleData, setSeedSampleData] = useState(false);
|
|
27
|
+
const [installing, setInstalling] = useState(false);
|
|
28
|
+
const [installResult, setInstallResult] = useState(null);
|
|
29
|
+
// Local-install state (this runtime's own kernel — separate flow from cloud).
|
|
30
|
+
const [localInstalls, setLocalInstalls] = useState([]);
|
|
31
|
+
const [installingLocal, setInstallingLocal] = useState(false);
|
|
32
|
+
const [localResult, setLocalResult] = useState(null);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
(async () => {
|
|
36
|
+
const items = await listLocalInstalls();
|
|
37
|
+
if (!cancelled)
|
|
38
|
+
setLocalInstalls(items);
|
|
39
|
+
})();
|
|
40
|
+
return () => { cancelled = true; };
|
|
41
|
+
}, [packageId, localResult]);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
(async () => {
|
|
45
|
+
if (!packageId)
|
|
46
|
+
return;
|
|
47
|
+
setLoading(true);
|
|
48
|
+
setError(null);
|
|
49
|
+
try {
|
|
50
|
+
const resp = await getMarketplacePackage(packageId);
|
|
51
|
+
if (!cancelled)
|
|
52
|
+
setData(resp);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
if (!cancelled)
|
|
56
|
+
setError(e?.message ?? String(e));
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (!cancelled)
|
|
60
|
+
setLoading(false);
|
|
61
|
+
}
|
|
62
|
+
})();
|
|
63
|
+
return () => { cancelled = true; };
|
|
64
|
+
}, [packageId]);
|
|
65
|
+
const openInstall = async () => {
|
|
66
|
+
setInstallOpen(true);
|
|
67
|
+
setInstallResult(null);
|
|
68
|
+
setEnvsError(null);
|
|
69
|
+
setEnvsLoading(true);
|
|
70
|
+
try {
|
|
71
|
+
const [list, adminOrgIds] = await Promise.all([
|
|
72
|
+
listCloudEnvironments(),
|
|
73
|
+
listInstallableOrgIds(),
|
|
74
|
+
]);
|
|
75
|
+
// Only envs in orgs where the caller is owner/admin are installable.
|
|
76
|
+
// Backend enforces the same gate; mirroring it here avoids confusing
|
|
77
|
+
// 403s and lets us show a helpful empty-state message.
|
|
78
|
+
const installable = list.filter((env) => {
|
|
79
|
+
const orgId = env.organization_id;
|
|
80
|
+
return orgId ? adminOrgIds.has(String(orgId)) : false;
|
|
81
|
+
});
|
|
82
|
+
setEnvs(installable);
|
|
83
|
+
if (list.length > 0 && installable.length === 0) {
|
|
84
|
+
setEnvsError('You do not have permission to install apps in any environment. ' +
|
|
85
|
+
'Only organization owners and admins can install — ask your workspace admin.');
|
|
86
|
+
}
|
|
87
|
+
else if (installable.length === 1) {
|
|
88
|
+
setSelectedEnv(installable[0].id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
const status = e?.status;
|
|
93
|
+
if (status === 401 || status === 403) {
|
|
94
|
+
setEnvsError('You need to sign into ObjectStack Cloud first. Click "Open on cloud" below.');
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
setEnvsError(e?.message ?? String(e));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
setEnvsLoading(false);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const doInstall = async () => {
|
|
105
|
+
if (!packageId || !selectedEnv)
|
|
106
|
+
return;
|
|
107
|
+
setInstalling(true);
|
|
108
|
+
setInstallResult(null);
|
|
109
|
+
try {
|
|
110
|
+
await installPackage({ packageId, environmentId: selectedEnv, seedSampleData });
|
|
111
|
+
setInstallResult({ ok: true, message: 'Installed successfully. Open the environment to see the new app.' });
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
setInstallResult({ ok: false, message: e?.message ?? String(e) });
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
setInstalling(false);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Install this package into the LOCAL runtime kernel (not a cloud env).
|
|
122
|
+
* Single-target operation — no picker, no dialog. Same-origin POST so
|
|
123
|
+
* cloud session is not required. The local AuthPlugin session is what
|
|
124
|
+
* the backend validates.
|
|
125
|
+
*/
|
|
126
|
+
const doInstallLocal = async () => {
|
|
127
|
+
if (!packageId)
|
|
128
|
+
return;
|
|
129
|
+
setInstallingLocal(true);
|
|
130
|
+
setLocalResult(null);
|
|
131
|
+
try {
|
|
132
|
+
const result = await installLocal({ packageId });
|
|
133
|
+
setLocalResult({
|
|
134
|
+
ok: true,
|
|
135
|
+
message: `Installed v${result.version} to this runtime. Refresh the console to see "${data?.package?.display_name ?? result.manifestId}" in the app switcher.`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
const code = e?.code;
|
|
140
|
+
let msg = e?.message ?? String(e);
|
|
141
|
+
if (code === 'manifest_conflict') {
|
|
142
|
+
msg = `${msg}\nTip: a local app already owns this manifest_id. Remove it from objectstack.config.ts first.`;
|
|
143
|
+
}
|
|
144
|
+
else if (code === 'unauthorized') {
|
|
145
|
+
msg = 'Sign in to this runtime first, then try again.';
|
|
146
|
+
}
|
|
147
|
+
else if (code === 'marketplace_unavailable') {
|
|
148
|
+
msg = 'This runtime has no OS_CLOUD_URL configured, so the marketplace catalog is unreachable.';
|
|
149
|
+
}
|
|
150
|
+
setLocalResult({ ok: false, message: msg });
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
setInstallingLocal(false);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Uninstall this package's cached manifest from the local runtime.
|
|
158
|
+
* NB: kernel API is additive only — the app remains live in the
|
|
159
|
+
* running kernel until the runtime restarts. We surface that
|
|
160
|
+
* caveat in the success message.
|
|
161
|
+
*/
|
|
162
|
+
const doUninstallLocal = async () => {
|
|
163
|
+
if (!localInstall)
|
|
164
|
+
return;
|
|
165
|
+
if (!confirm(`Uninstall ${localInstall.manifestId} v${localInstall.version} from this runtime?\n\nThe cached manifest will be removed. The app will remain loaded in the running kernel until the next restart.`)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
setInstallingLocal(true);
|
|
169
|
+
setLocalResult(null);
|
|
170
|
+
try {
|
|
171
|
+
await uninstallLocal(localInstall.manifestId);
|
|
172
|
+
setLocalResult({
|
|
173
|
+
ok: true,
|
|
174
|
+
message: `Removed cached manifest for ${localInstall.manifestId}. Restart the runtime to fully unload the app from the running kernel.`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
setLocalResult({ ok: false, message: e?.message ?? String(e) });
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
setInstallingLocal(false);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
if (loading) {
|
|
185
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 p-4 sm:p-6", children: [_jsx(Skeleton, { className: "h-8 w-32" }), _jsx(Skeleton, { className: "h-16 w-full" }), _jsx(Skeleton, { className: "h-64 w-full" })] }));
|
|
186
|
+
}
|
|
187
|
+
if (error || !data) {
|
|
188
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 p-4 sm:p-6", children: [_jsxs(Button, { variant: "ghost", size: "sm", 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 gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm", children: [_jsx(AlertCircle, { className: "h-4 w-4 mt-0.5 text-destructive", "aria-hidden": "true" }), _jsxs("div", { children: [_jsx("div", { className: "font-medium text-destructive", children: "Failed to load package" }), _jsx("div", { className: "text-muted-foreground mt-1", children: error ?? 'Not found.' })] })] })] }));
|
|
189
|
+
}
|
|
190
|
+
const pkg = data.package;
|
|
191
|
+
const latestVersion = pkg.latest_version?.version ?? data.versions[0]?.version ?? null;
|
|
192
|
+
const localInstall = localInstalls.find((i) => i.manifestId === pkg.manifest_id) ?? null;
|
|
193
|
+
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 gap-4 flex-wrap", children: [_jsx(PackageIcon, { iconUrl: pkg.icon_url, displayName: pkg.display_name, manifestId: pkg.manifest_id, className: "h-16 w-16 rounded-xl", initialClassName: "text-2xl font-bold" }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight truncate", children: pkg.display_name || pkg.manifest_id }), _jsxs("div", { className: "text-sm text-muted-foreground mt-1 flex flex-wrap items-center gap-2", children: [_jsx("code", { className: "font-mono", children: pkg.manifest_id }), latestVersion && (_jsxs(Badge, { variant: "outline", children: ["v", latestVersion] })), pkg.publisher && pkg.publisher !== 'private' && (_jsx(Badge, { variant: pkg.publisher === 'objectstack' ? 'default' : 'secondary', children: pkg.publisher })), pkg.category && _jsx(Badge, { variant: "outline", children: pkg.category }), pkg.license && _jsxs("span", { className: "text-xs", children: ["License: ", pkg.license] }), localInstall && (_jsxs(Badge, { variant: "default", className: "bg-green-600 hover:bg-green-600", children: ["Installed locally \u00B7 v", localInstall.version] }))] }), pkg.description && (_jsx("p", { className: "text-sm text-muted-foreground mt-3 max-w-2xl", children: pkg.description }))] }), _jsxs("div", { className: "flex flex-col gap-2 shrink-0 min-w-[14rem]", children: [_jsxs(Button, { onClick: doInstallLocal, disabled: !latestVersion || installingLocal, children: [_jsx(Download, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), installingLocal
|
|
194
|
+
? 'Working…'
|
|
195
|
+
: localInstall
|
|
196
|
+
? `Reinstall to this runtime`
|
|
197
|
+
: 'Install to this runtime'] }), localInstall && (_jsxs(Button, { variant: "outline", onClick: doUninstallLocal, disabled: installingLocal, children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Uninstall from this runtime"] })), _jsxs(Button, { variant: "outline", onClick: openInstall, disabled: !latestVersion, children: [_jsx(Download, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Install to cloud environment\u2026"] }), pkg.homepage_url && (_jsx("a", { href: pkg.homepage_url, target: "_blank", rel: "noopener noreferrer", children: _jsxs(Button, { variant: "ghost", className: "w-full", children: [_jsx(ExternalLink, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Homepage"] }) })), localResult && (_jsx("div", { className: `rounded-md border p-2 text-xs whitespace-pre-wrap ${localResult.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: localResult.message }))] })] }), _jsxs("div", { className: "grid gap-4 lg:grid-cols-3", children: [_jsx("div", { className: "lg:col-span-2 space-y-4", children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "About" }) }), _jsx(CardContent, { children: pkg.readme ? (_jsx("pre", { className: "whitespace-pre-wrap text-sm font-sans", children: pkg.readme })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "No readme provided." })) })] }) }), _jsx("div", { className: "space-y-4", children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Versions" }) }), _jsx(CardContent, { children: data.versions.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No approved versions." })) : (_jsx("ul", { className: "space-y-2", children: data.versions.map((v) => (_jsxs("li", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx(Package, { className: "h-3.5 w-3.5 text-muted-foreground", "aria-hidden": "true" }), _jsxs("code", { className: "font-mono", children: ["v", v.version] }), v.is_prerelease && _jsx(Badge, { variant: "outline", className: "text-xs", children: "pre" })] }), _jsx("span", { className: "text-xs text-muted-foreground", children: v.published_at ? new Date(v.published_at).toLocaleDateString() : '—' })] }, v.id))) })) })] }) })] }), _jsx(Dialog, { open: installOpen, onOpenChange: (o) => { setInstallOpen(o); if (!o)
|
|
198
|
+
setInstallResult(null); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsxs(DialogTitle, { children: ["Install ", pkg.display_name || pkg.manifest_id] }), _jsx(DialogDescription, { children: "Choose an environment to install this app into. You need to be signed into ObjectStack Cloud." })] }), envsLoading ? (_jsx(Skeleton, { className: "h-10 w-full" })) : envsError ? (_jsxs("div", { className: "rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-sm space-y-2", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(AlertCircle, { className: "h-4 w-4 mt-0.5 text-amber-600", "aria-hidden": "true" }), _jsx("div", { className: "flex-1", children: envsError })] }), _jsx("a", { href: cloudInstallDeepLink(pkg.id), target: "_blank", rel: "noopener noreferrer", children: _jsxs(Button, { variant: "outline", size: "sm", className: "w-full", children: [_jsx(ExternalLink, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Open on cloud"] }) })] })) : envs.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "No environments found in your active organization." })) : (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "env-select", children: "Environment" }), _jsxs(Select, { value: selectedEnv, onValueChange: setSelectedEnv, children: [_jsx(SelectTrigger, { id: "env-select", children: _jsx(SelectValue, { placeholder: "Pick an environment" }) }), _jsx(SelectContent, { children: envs.map((e) => (_jsxs(SelectItem, { value: e.id, children: [e.display_name || e.hostname || e.id, e.plan && _jsxs("span", { className: "text-muted-foreground", children: [" \u00B7 ", e.plan] })] }, e.id))) })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Checkbox, { id: "seed", checked: seedSampleData, onCheckedChange: (c) => setSeedSampleData(c === true) }), _jsx(Label, { htmlFor: "seed", className: "text-sm font-normal cursor-pointer", children: "Include sample data" })] })] })), installResult && (_jsx("div", { className: `rounded-md border p-3 text-sm ${installResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700' : 'border-destructive/30 bg-destructive/5 text-destructive'}`, children: installResult.message })), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => setInstallOpen(false), children: "Close" }), !envsError && (_jsx(Button, { onClick: doInstall, disabled: !selectedEnv || installing || installResult?.ok === true, children: installing ? 'Installing…' : 'Install' }))] })] }) })] }));
|
|
199
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace Page — browse approved, marketplace-listed packages.
|
|
3
|
+
*
|
|
4
|
+
* Card-grid catalog of public packages, fetched from the tenant runtime's
|
|
5
|
+
* `/api/v1/marketplace/packages` proxy (which forwards to the configured
|
|
6
|
+
* cloud control plane). Click-through opens the package detail page.
|
|
7
|
+
*/
|
|
8
|
+
export declare function MarketplacePage(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Marketplace Page — browse approved, marketplace-listed packages.
|
|
4
|
+
*
|
|
5
|
+
* Card-grid catalog of public packages, fetched from the tenant runtime's
|
|
6
|
+
* `/api/v1/marketplace/packages` proxy (which forwards to the configured
|
|
7
|
+
* cloud control plane). Click-through opens the package detail page.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
10
|
+
import { useNavigate, useParams } from 'react-router-dom';
|
|
11
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Badge, Input, Button, Skeleton, } from '@object-ui/components';
|
|
12
|
+
import { Package, Search, RefreshCcw, Store, AlertCircle, CheckCircle2, Settings } from 'lucide-react';
|
|
13
|
+
import { PackageIcon } from './PackageIcon';
|
|
14
|
+
import { listMarketplacePackages, listLocalInstalls, } from './marketplaceApi';
|
|
15
|
+
function formatRelative(iso) {
|
|
16
|
+
if (!iso)
|
|
17
|
+
return '';
|
|
18
|
+
const d = new Date(iso);
|
|
19
|
+
if (Number.isNaN(d.getTime()))
|
|
20
|
+
return '';
|
|
21
|
+
const days = Math.floor((Date.now() - d.getTime()) / 86400000);
|
|
22
|
+
if (days < 1)
|
|
23
|
+
return 'today';
|
|
24
|
+
if (days < 30)
|
|
25
|
+
return `${days}d ago`;
|
|
26
|
+
if (days < 365)
|
|
27
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
28
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
29
|
+
}
|
|
30
|
+
export function MarketplacePage() {
|
|
31
|
+
const navigate = useNavigate();
|
|
32
|
+
const { appName } = useParams();
|
|
33
|
+
const basePath = appName ? `/apps/${appName}` : '';
|
|
34
|
+
const [items, setItems] = useState([]);
|
|
35
|
+
const [loading, setLoading] = useState(true);
|
|
36
|
+
const [error, setError] = useState(null);
|
|
37
|
+
const [query, setQuery] = useState('');
|
|
38
|
+
const [category, setCategory] = useState('');
|
|
39
|
+
const [installed, setInstalled] = useState([]);
|
|
40
|
+
const installedByManifestId = useMemo(() => {
|
|
41
|
+
const m = new Map();
|
|
42
|
+
for (const e of installed)
|
|
43
|
+
m.set(e.manifestId, e);
|
|
44
|
+
return m;
|
|
45
|
+
}, [installed]);
|
|
46
|
+
const load = async () => {
|
|
47
|
+
setLoading(true);
|
|
48
|
+
setError(null);
|
|
49
|
+
try {
|
|
50
|
+
const [resp, installs] = await Promise.all([
|
|
51
|
+
listMarketplacePackages({ limit: 100 }),
|
|
52
|
+
listLocalInstalls(),
|
|
53
|
+
]);
|
|
54
|
+
setItems(resp.items ?? []);
|
|
55
|
+
setInstalled(installs);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
setError(e?.message ?? String(e));
|
|
59
|
+
setItems([]);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
setLoading(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
useEffect(() => { void load(); }, []);
|
|
66
|
+
const categories = useMemo(() => {
|
|
67
|
+
const s = new Set();
|
|
68
|
+
for (const it of items) {
|
|
69
|
+
if (it.category)
|
|
70
|
+
s.add(it.category);
|
|
71
|
+
}
|
|
72
|
+
return Array.from(s).sort();
|
|
73
|
+
}, [items]);
|
|
74
|
+
const filtered = useMemo(() => {
|
|
75
|
+
const q = query.trim().toLowerCase();
|
|
76
|
+
return items.filter((it) => {
|
|
77
|
+
if (category && it.category !== category)
|
|
78
|
+
return false;
|
|
79
|
+
if (!q)
|
|
80
|
+
return true;
|
|
81
|
+
const hay = `${it.display_name} ${it.manifest_id} ${it.description ?? ''}`.toLowerCase();
|
|
82
|
+
return hay.includes(q);
|
|
83
|
+
});
|
|
84
|
+
}, [items, query, category]);
|
|
85
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 p-4 sm:p-6", children: [_jsxs("div", { className: "flex items-start justify-between gap-4 flex-wrap", children: [_jsxs("div", { className: "min-w-0", 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: "App Marketplace" })] }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: "Browse approved apps published to the ObjectStack catalog. Click an app to view details and install it into one of your environments." })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Button, { variant: "outline", size: "sm", onClick: () => navigate(`${basePath}/system/marketplace/installed`), children: [_jsx(Settings, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), "Installed", installed.length > 0 ? ` (${installed.length})` : ''] }), _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"] })] })] }), _jsxs("div", { className: "flex flex-col sm:flex-row gap-3", children: [_jsxs("div", { className: "relative flex-1", children: [_jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground", "aria-hidden": "true" }), _jsx(Input, { value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search apps by name or manifest ID\u2026", className: "pl-9", "aria-label": "Search marketplace apps" })] }), categories.length > 0 && (_jsxs("div", { className: "flex flex-wrap gap-1.5", children: [_jsx(Button, { size: "sm", variant: category === '' ? 'default' : 'outline', onClick: () => setCategory(''), children: "All" }), categories.map((cat) => (_jsx(Button, { size: "sm", variant: category === cat ? 'default' : 'outline', onClick: () => setCategory(cat), children: cat }, cat)))] }))] }), error && (_jsxs("div", { className: "flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm", children: [_jsx(AlertCircle, { className: "h-4 w-4 mt-0.5 text-destructive", "aria-hidden": "true" }), _jsxs("div", { children: [_jsx("div", { className: "font-medium text-destructive", children: "Failed to load marketplace" }), _jsx("div", { className: "text-muted-foreground mt-1", children: error }), _jsxs("div", { className: "text-xs text-muted-foreground mt-2", children: ["Check that this runtime is configured with ", _jsx("code", { className: "font-mono", children: "OS_CLOUD_URL" }), " pointing at a reachable ObjectStack Cloud."] })] })] })), loading && items.length === 0 ? (_jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "pb-2", children: [_jsx(Skeleton, { className: "h-10 w-10 rounded-lg mb-2" }), _jsx(Skeleton, { className: "h-5 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2 mt-1" })] }), _jsx(CardContent, { children: _jsx(Skeleton, { className: "h-12 w-full" }) })] }, i))) })) : filtered.length === 0 ? (_jsx("div", { className: "text-center py-12 text-sm text-muted-foreground", children: items.length === 0 ? 'No apps have been approved for the marketplace yet.' : 'No apps match your filters.' })) : (_jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3", children: filtered.map((pkg) => {
|
|
86
|
+
const localEntry = installedByManifestId.get(pkg.manifest_id);
|
|
87
|
+
return (_jsxs(Card, { className: "cursor-pointer transition-colors hover:bg-accent/50 flex flex-col", onClick: () => navigate(`${basePath}/system/marketplace/${pkg.id}`), role: "link", tabIndex: 0, onKeyDown: (e) => {
|
|
88
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
navigate(`${basePath}/system/marketplace/${pkg.id}`);
|
|
91
|
+
}
|
|
92
|
+
}, "data-testid": `marketplace-card-${pkg.manifest_id}`, children: [_jsxs(CardHeader, { className: "flex flex-row items-start gap-3 pb-2", children: [_jsx(PackageIcon, { iconUrl: pkg.icon_url, displayName: pkg.display_name, manifestId: pkg.manifest_id }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx(CardTitle, { className: "text-base truncate", children: pkg.display_name || pkg.manifest_id }), _jsx(CardDescription, { className: "text-xs truncate", children: _jsx("code", { className: "font-mono", children: pkg.manifest_id }) })] }), pkg.publisher && pkg.publisher !== 'private' && (_jsx(Badge, { variant: pkg.publisher === 'objectstack' ? 'default' : 'secondary', className: "shrink-0", children: pkg.publisher }))] }), _jsxs(CardContent, { className: "flex-1 flex flex-col gap-2", children: [_jsx("p", { className: "text-sm text-muted-foreground line-clamp-2", children: pkg.description || 'No description provided.' }), _jsxs("div", { className: "flex items-center gap-2 mt-auto pt-2 flex-wrap", children: [localEntry && (_jsxs(Badge, { variant: "default", className: "text-xs bg-green-600 hover:bg-green-600", children: [_jsx(CheckCircle2, { className: "h-3 w-3 mr-1", "aria-hidden": "true" }), "Installed v", localEntry.version] })), pkg.latest_version?.version && (_jsxs(Badge, { variant: "outline", className: "text-xs", children: [_jsx(Package, { className: "h-3 w-3 mr-1", "aria-hidden": "true" }), "v", pkg.latest_version.version] })), pkg.category && (_jsx(Badge, { variant: "outline", className: "text-xs", children: pkg.category })), pkg.latest_version?.published_at && (_jsx("span", { className: "text-xs text-muted-foreground ml-auto", children: formatRelative(pkg.latest_version.published_at) }))] })] })] }, pkg.id));
|
|
93
|
+
}) }))] }));
|
|
94
|
+
}
|