@object-ui/app-shell 6.1.0 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +10 -1
- package/dist/console/AppContent.js +24 -2
- package/dist/console/ai/AiChatPage.d.ts +8 -0
- package/dist/console/ai/AiChatPage.js +188 -0
- package/dist/console/ai/ConversationsSidebar.d.ts +7 -0
- package/dist/console/ai/ConversationsSidebar.js +111 -0
- package/dist/console/auth/LoginPage.js +19 -2
- package/dist/console/auth/RegisterPage.js +30 -1
- package/dist/console/marketplace/MarketplaceAccessDenied.js +3 -1
- package/dist/console/marketplace/MarketplaceInstalledPage.js +11 -3
- package/dist/console/marketplace/MarketplacePackagePage.js +57 -19
- package/dist/console/marketplace/MarketplacePage.js +55 -18
- package/dist/console/marketplace/marketplaceApi.d.ts +20 -0
- package/dist/console/marketplace/usePackageL10n.d.ts +38 -0
- package/dist/console/marketplace/usePackageL10n.js +110 -0
- package/dist/console/organizations/CreateWorkspaceDialog.js +29 -1
- package/dist/console/organizations/OrganizationsPage.js +24 -3
- package/dist/context/FavoritesProvider.d.ts +40 -2
- package/dist/context/FavoritesProvider.js +201 -20
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useChatConversation.d.ts +7 -0
- package/dist/hooks/useChatConversation.js +37 -5
- package/dist/hooks/useConversationList.d.ts +25 -0
- package/dist/hooks/useConversationList.js +131 -0
- package/dist/hooks/useNavPins.d.ts +11 -4
- package/dist/hooks/useNavPins.js +104 -53
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/layout/AppHeader.js +2 -2
- package/dist/layout/AppSidebar.js +20 -1
- package/dist/layout/UnifiedSidebar.js +1 -1
- package/dist/providers/ExpressionProvider.d.ts +11 -1
- package/dist/providers/ExpressionProvider.js +11 -6
- package/dist/services/builtinComponents.d.ts +1 -0
- package/dist/services/builtinComponents.js +166 -0
- package/dist/services/componentRegistry.d.ts +63 -0
- package/dist/services/componentRegistry.js +36 -0
- package/dist/views/ComponentNavView.d.ts +6 -0
- package/dist/views/ComponentNavView.js +26 -0
- package/dist/views/RecordDetailView.js +66 -6
- package/dist/views/RecordFormPage.js +15 -3
- package/dist/views/SearchResultsPage.js +4 -0
- package/dist/views/metadata-admin/DesignerEditorWrapper.d.ts +58 -0
- package/dist/views/metadata-admin/DesignerEditorWrapper.js +140 -0
- package/dist/views/metadata-admin/DirectoryPage.d.ts +1 -0
- package/dist/views/metadata-admin/DirectoryPage.js +135 -0
- package/dist/views/metadata-admin/LayeredDiff.d.ts +6 -0
- package/dist/views/metadata-admin/LayeredDiff.js +26 -0
- package/dist/views/metadata-admin/PageShell.d.ts +34 -0
- package/dist/views/metadata-admin/PageShell.js +33 -0
- package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +5 -0
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +288 -0
- package/dist/views/metadata-admin/QuickFind.d.ts +5 -0
- package/dist/views/metadata-admin/QuickFind.js +152 -0
- package/dist/views/metadata-admin/ResourceEditPage.d.ts +7 -0
- package/dist/views/metadata-admin/ResourceEditPage.js +256 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +5 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.js +97 -0
- package/dist/views/metadata-admin/ResourceListPage.d.ts +4 -0
- package/dist/views/metadata-admin/ResourceListPage.js +144 -0
- package/dist/views/metadata-admin/ResourceRouter.d.ts +10 -0
- package/dist/views/metadata-admin/ResourceRouter.js +47 -0
- package/dist/views/metadata-admin/SchemaForm.d.ts +99 -0
- package/dist/views/metadata-admin/SchemaForm.js +556 -0
- package/dist/views/metadata-admin/default-schemas.d.ts +6 -0
- package/dist/views/metadata-admin/default-schemas.js +207 -0
- package/dist/views/metadata-admin/i18n.d.ts +33 -0
- package/dist/views/metadata-admin/i18n.js +303 -0
- package/dist/views/metadata-admin/index.d.ts +31 -0
- package/dist/views/metadata-admin/index.js +33 -0
- package/dist/views/metadata-admin/predicate.d.ts +31 -0
- package/dist/views/metadata-admin/predicate.js +150 -0
- package/dist/views/metadata-admin/registry.d.ts +125 -0
- package/dist/views/metadata-admin/registry.js +48 -0
- package/dist/views/metadata-admin/useMetadata.d.ts +37 -0
- package/dist/views/metadata-admin/useMetadata.js +96 -0
- package/dist/views/metadata-admin/widgets.d.ts +68 -0
- package/dist/views/metadata-admin/widgets.js +287 -0
- package/package.json +27 -26
|
@@ -20,12 +20,14 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|
|
20
20
|
import { Card, CardContent, CardHeader, CardTitle, Badge, Button, Skeleton, } from '@object-ui/components';
|
|
21
21
|
import { ArrowLeft, RefreshCcw, Store, Trash2, AlertCircle, ExternalLink } from 'lucide-react';
|
|
22
22
|
import { useIsWorkspaceAdmin } from '@object-ui/auth';
|
|
23
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
23
24
|
import { listLocalInstalls, uninstallLocal, } from './marketplaceApi';
|
|
24
25
|
import { MarketplaceAccessDenied } from './MarketplaceAccessDenied';
|
|
25
26
|
export function MarketplaceInstalledPage() {
|
|
26
27
|
const navigate = useNavigate();
|
|
27
28
|
const { appName } = useParams();
|
|
28
29
|
const isAdmin = useIsWorkspaceAdmin();
|
|
30
|
+
const { t, language } = useObjectTranslation();
|
|
29
31
|
const basePath = appName ? `/apps/${appName}` : '';
|
|
30
32
|
const [items, setItems] = useState([]);
|
|
31
33
|
const [loading, setLoading] = useState(true);
|
|
@@ -43,7 +45,7 @@ export function MarketplaceInstalledPage() {
|
|
|
43
45
|
};
|
|
44
46
|
useEffect(() => { void load(); }, []);
|
|
45
47
|
const doUninstall = async (entry) => {
|
|
46
|
-
if (!confirm(
|
|
48
|
+
if (!confirm(t('marketplace.uninstall.confirm', { manifestId: entry.manifestId, version: entry.version }))) {
|
|
47
49
|
return;
|
|
48
50
|
}
|
|
49
51
|
setWorking(entry.manifestId);
|
|
@@ -52,7 +54,7 @@ export function MarketplaceInstalledPage() {
|
|
|
52
54
|
await uninstallLocal(entry.manifestId);
|
|
53
55
|
setResult({
|
|
54
56
|
ok: true,
|
|
55
|
-
message:
|
|
57
|
+
message: t('marketplace.uninstall.successInList', { manifestId: entry.manifestId }),
|
|
56
58
|
});
|
|
57
59
|
await load();
|
|
58
60
|
}
|
|
@@ -65,5 +67,11 @@ export function MarketplaceInstalledPage() {
|
|
|
65
67
|
};
|
|
66
68
|
if (!isAdmin)
|
|
67
69
|
return _jsx(MarketplaceAccessDenied, {});
|
|
68
|
-
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" }),
|
|
70
|
+
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" }), t('marketplace.back')] }), _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: t('marketplace.installedTitle') })] }), _jsx("p", { className: "text-sm text-muted-foreground mt-1",
|
|
71
|
+
// Subtitle contains an inline <code> path; render translated HTML from our own bundle.
|
|
72
|
+
dangerouslySetInnerHTML: { __html: t('marketplace.installedSubtitle') } })] }), _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" }), t('marketplace.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: t('marketplace.installedEmpty') }), _jsx(Button, { variant: "link", className: "mt-2", onClick: () => navigate(`${basePath}/system/marketplace`), children: t('marketplace.browseLink') })] })) : (_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, _jsx(Badge, { variant: "outline", children: t('marketplace.versionBadge', { version: entry.version }) })] }), _jsxs("div", { className: "text-xs text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1", children: [_jsx("span", { children: t('marketplace.installedAt', { when: new Date(entry.installedAt).toLocaleString(language || undefined) }) }), entry.installedBy && _jsx("span", { children: t('marketplace.installedBy', { user: entry.installedBy }) }), _jsxs("span", { children: [t('marketplace.installedPackageId'), " ", _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" }), t('marketplace.action.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 ? t('marketplace.action.uninstalling') : t('marketplace.action.uninstall')] })] })] }), _jsx(CardContent, { className: "pt-0 text-xs text-muted-foreground", dangerouslySetInnerHTML: {
|
|
73
|
+
__html: t('marketplace.cachedAs', {
|
|
74
|
+
path: `.objectstack/installed-packages/${entry.manifestId.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`,
|
|
75
|
+
}),
|
|
76
|
+
} })] }, entry.manifestId))) })), _jsx("p", { className: "text-xs text-muted-foreground border-t pt-4", dangerouslySetInnerHTML: { __html: t('marketplace.installedAdditiveNote') } })] }));
|
|
69
77
|
}
|
|
@@ -10,9 +10,11 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|
|
10
10
|
import { Button, Badge, Card, CardContent, CardHeader, CardTitle, Skeleton, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Label, Checkbox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from '@object-ui/components';
|
|
11
11
|
import { ArrowLeft, ExternalLink, Download, AlertCircle, Package, Trash2, MoreHorizontal, CheckCircle2 } from 'lucide-react';
|
|
12
12
|
import { useIsWorkspaceAdmin } from '@object-ui/auth';
|
|
13
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
13
14
|
import { PackageIcon } from './PackageIcon';
|
|
14
15
|
import { MarkdownText } from './MarkdownText';
|
|
15
16
|
import { MarketplaceAccessDenied } from './MarketplaceAccessDenied';
|
|
17
|
+
import { localizePackage } from './usePackageL10n';
|
|
16
18
|
import { getMarketplacePackage, installPackage, installLocal, uninstallLocal, listLocalInstalls, listCloudEnvironments, listInstallableOrgIds, cloudInstallDeepLink, } from './marketplaceApi';
|
|
17
19
|
import { getRuntimeConfig } from '../../runtime-config';
|
|
18
20
|
import { useMetadata } from '../../providers/MetadataProvider';
|
|
@@ -20,6 +22,7 @@ export function MarketplacePackagePage() {
|
|
|
20
22
|
const navigate = useNavigate();
|
|
21
23
|
const { packageId, appName } = useParams();
|
|
22
24
|
const isAdmin = useIsWorkspaceAdmin();
|
|
25
|
+
const { t, language } = useObjectTranslation();
|
|
23
26
|
const basePath = appName ? `/apps/${appName}` : '';
|
|
24
27
|
const { refresh: refreshMetadata } = useMetadata();
|
|
25
28
|
const [data, setData] = useState(null);
|
|
@@ -102,8 +105,7 @@ export function MarketplacePackagePage() {
|
|
|
102
105
|
});
|
|
103
106
|
setEnvs(installable);
|
|
104
107
|
if (list.length > 0 && installable.length === 0) {
|
|
105
|
-
setEnvsError('
|
|
106
|
-
'Only organization owners and admins can install — ask your workspace admin.');
|
|
108
|
+
setEnvsError(t('marketplace.install.noPermission'));
|
|
107
109
|
}
|
|
108
110
|
else if (installable.length === 1) {
|
|
109
111
|
setSelectedEnv(installable[0].id);
|
|
@@ -112,7 +114,7 @@ export function MarketplacePackagePage() {
|
|
|
112
114
|
catch (e) {
|
|
113
115
|
const status = e?.status;
|
|
114
116
|
if (status === 401 || status === 403) {
|
|
115
|
-
setEnvsError('
|
|
117
|
+
setEnvsError(t('marketplace.install.signInFirst'));
|
|
116
118
|
}
|
|
117
119
|
else {
|
|
118
120
|
setEnvsError(e?.message ?? String(e));
|
|
@@ -129,7 +131,36 @@ export function MarketplacePackagePage() {
|
|
|
129
131
|
setInstallResult(null);
|
|
130
132
|
try {
|
|
131
133
|
await installPackage({ packageId, environmentId: selectedEnv, seedSampleData });
|
|
132
|
-
|
|
134
|
+
// Invalidate the metadata cache so the newly-installed app's
|
|
135
|
+
// objects/views/menus are fetched fresh on next access. Without
|
|
136
|
+
// this the user sees the new app in the switcher (the `app` list
|
|
137
|
+
// gets refreshed) but clicking a menu entry fails with "metadata
|
|
138
|
+
// not found" because objects/views/etc. are still cached from
|
|
139
|
+
// before the install. Only useful when installing into the env
|
|
140
|
+
// currently rendered by this SPA — for cross-env installs the
|
|
141
|
+
// refresh is a harmless no-op (re-fetches the current env's
|
|
142
|
+
// metadata, which is unchanged).
|
|
143
|
+
const currentEnvId = getRuntimeConfig().defaultEnvironmentId;
|
|
144
|
+
if (!currentEnvId || currentEnvId === selectedEnv) {
|
|
145
|
+
try {
|
|
146
|
+
// Drop the persisted `app` cache too — refresh() overwrites it
|
|
147
|
+
// when the new app list comes back, but clearing first protects
|
|
148
|
+
// against partial failures leaving stale data on next reload.
|
|
149
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
150
|
+
for (const key of Object.keys(sessionStorage)) {
|
|
151
|
+
if (key.startsWith('objectui:metadata:')) {
|
|
152
|
+
sessionStorage.removeItem(key);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
await refreshMetadata();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Non-fatal: install succeeded; worst case the user navigates
|
|
160
|
+
// away and back to pick up the new metadata.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
setInstallResult({ ok: true, message: t('marketplace.install.success') });
|
|
133
164
|
}
|
|
134
165
|
catch (e) {
|
|
135
166
|
setInstallResult({ ok: false, message: e?.message ?? String(e) });
|
|
@@ -159,20 +190,23 @@ export function MarketplacePackagePage() {
|
|
|
159
190
|
}
|
|
160
191
|
setLocalResult({
|
|
161
192
|
ok: true,
|
|
162
|
-
message:
|
|
193
|
+
message: t('marketplace.install.localSuccess', {
|
|
194
|
+
version: result.version,
|
|
195
|
+
name: data?.package ? localizePackage(data.package, language).displayName : result.manifestId,
|
|
196
|
+
}),
|
|
163
197
|
});
|
|
164
198
|
}
|
|
165
199
|
catch (e) {
|
|
166
200
|
const code = e?.code;
|
|
167
201
|
let msg = e?.message ?? String(e);
|
|
168
202
|
if (code === 'manifest_conflict') {
|
|
169
|
-
msg =
|
|
203
|
+
msg = t('marketplace.install.localManifestConflict', { message: msg });
|
|
170
204
|
}
|
|
171
205
|
else if (code === 'unauthorized') {
|
|
172
|
-
msg = '
|
|
206
|
+
msg = t('marketplace.install.localUnauthorized');
|
|
173
207
|
}
|
|
174
208
|
else if (code === 'marketplace_unavailable') {
|
|
175
|
-
msg = '
|
|
209
|
+
msg = t('marketplace.install.localMarketplaceUnavailable');
|
|
176
210
|
}
|
|
177
211
|
setLocalResult({ ok: false, message: msg });
|
|
178
212
|
}
|
|
@@ -189,7 +223,7 @@ export function MarketplacePackagePage() {
|
|
|
189
223
|
const doUninstallLocal = async () => {
|
|
190
224
|
if (!localInstall)
|
|
191
225
|
return;
|
|
192
|
-
if (!confirm(
|
|
226
|
+
if (!confirm(t('marketplace.uninstall.confirm', { manifestId: localInstall.manifestId, version: localInstall.version }))) {
|
|
193
227
|
return;
|
|
194
228
|
}
|
|
195
229
|
setInstallingLocal(true);
|
|
@@ -204,7 +238,7 @@ export function MarketplacePackagePage() {
|
|
|
204
238
|
}
|
|
205
239
|
setLocalResult({
|
|
206
240
|
ok: true,
|
|
207
|
-
message:
|
|
241
|
+
message: t('marketplace.uninstall.successInDetail', { manifestId: localInstall.manifestId }),
|
|
208
242
|
});
|
|
209
243
|
}
|
|
210
244
|
catch (e) {
|
|
@@ -218,9 +252,10 @@ export function MarketplacePackagePage() {
|
|
|
218
252
|
return (_jsxs("div", { className: "mx-auto w-full max-w-6xl 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" })] }));
|
|
219
253
|
}
|
|
220
254
|
if (error || !data) {
|
|
221
|
-
return (_jsxs("div", { className: "mx-auto w-full max-w-6xl 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" }),
|
|
255
|
+
return (_jsxs("div", { className: "mx-auto w-full max-w-6xl 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" }), t('marketplace.back')] }), _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: t('marketplace.load.packageFailed') }), _jsx("div", { className: "text-muted-foreground mt-1", children: error ?? t('marketplace.load.notFound') })] })] })] }));
|
|
222
256
|
}
|
|
223
257
|
const pkg = data.package;
|
|
258
|
+
const loc = localizePackage(pkg, language);
|
|
224
259
|
const latestVersion = pkg.latest_version?.version ?? data.versions[0]?.version ?? null;
|
|
225
260
|
const localInstall = localInstalls.find((i) => i.manifestId === pkg.manifest_id) ?? null;
|
|
226
261
|
const supportsLocal = getRuntimeConfig().features.installLocal;
|
|
@@ -228,20 +263,23 @@ export function MarketplacePackagePage() {
|
|
|
228
263
|
const primaryAction = supportsLocal
|
|
229
264
|
? {
|
|
230
265
|
label: installingLocal
|
|
231
|
-
? '
|
|
266
|
+
? t('marketplace.action.working')
|
|
232
267
|
: localInstall
|
|
233
|
-
? '
|
|
234
|
-
: '
|
|
268
|
+
? t('marketplace.action.reinstall')
|
|
269
|
+
: t('marketplace.action.install'),
|
|
235
270
|
onClick: doInstallLocal,
|
|
236
271
|
}
|
|
237
272
|
: {
|
|
238
|
-
label: installing ? '
|
|
273
|
+
label: installing ? t('marketplace.action.installing') : t('marketplace.action.installToCloud'),
|
|
239
274
|
onClick: openInstall,
|
|
240
275
|
};
|
|
276
|
+
const categoryLabel = pkg.category
|
|
277
|
+
? t(`marketplace.category.${pkg.category}`, { defaultValue: pkg.category })
|
|
278
|
+
: null;
|
|
241
279
|
if (!isAdmin)
|
|
242
280
|
return _jsx(MarketplaceAccessDenied, {});
|
|
243
|
-
return (_jsxs("div", { className: "mx-auto w-full max-w-6xl flex flex-col gap-6 p-4 sm:p-6", children: [_jsxs(Button, { variant: "ghost", size: "sm", className: "self-start -ml-2 text-muted-foreground hover:text-foreground", onClick: () => navigate(`${basePath}/system/marketplace`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }),
|
|
244
|
-
setInstallResult(null); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [
|
|
245
|
-
?
|
|
246
|
-
: '
|
|
281
|
+
return (_jsxs("div", { className: "mx-auto w-full max-w-6xl flex flex-col gap-6 p-4 sm:p-6", children: [_jsxs(Button, { variant: "ghost", size: "sm", className: "self-start -ml-2 text-muted-foreground hover:text-foreground", onClick: () => navigate(`${basePath}/system/marketplace`), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), t('marketplace.back')] }), _jsxs("div", { className: "flex items-start gap-5 flex-wrap sm:flex-nowrap rounded-2xl border bg-gradient-to-br from-primary/5 via-background to-background p-6 sm:p-8", children: [_jsx(PackageIcon, { iconUrl: pkg.icon_url, displayName: loc.displayName, manifestId: pkg.manifest_id, className: "h-20 w-20 rounded-2xl shadow-sm ring-1 ring-border shrink-0", initialClassName: "text-3xl font-bold" }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("h1", { className: "text-2xl sm:text-3xl font-bold tracking-tight truncate", children: loc.displayName || pkg.manifest_id }), pkg.homepage_url && (_jsxs("a", { href: pkg.homepage_url, target: "_blank", rel: "noopener noreferrer", className: "inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors", title: t('marketplace.detail.homepage'), children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5", "aria-hidden": "true" }), _jsx("span", { className: "hidden sm:inline", children: t('marketplace.detail.homepage') })] }))] }), _jsxs("div", { className: "text-sm text-muted-foreground mt-2 flex flex-wrap items-center gap-1.5", children: [_jsx("code", { className: "font-mono text-xs px-1.5 py-0.5 rounded bg-muted", children: pkg.manifest_id }), latestVersion && _jsx(Badge, { variant: "outline", children: t('marketplace.versionBadge', { version: latestVersion }) }), pkg.publisher && pkg.publisher !== 'private' && (_jsx(Badge, { variant: pkg.publisher === 'objectstack' ? 'default' : 'secondary', children: pkg.publisher })), categoryLabel && _jsx(Badge, { variant: "outline", children: categoryLabel }), pkg.license && _jsx(Badge, { variant: "outline", className: "font-normal", children: pkg.license }), localInstall && (_jsxs(Badge, { variant: "default", className: "bg-green-600 hover:bg-green-600 gap-1", children: [_jsx(CheckCircle2, { className: "h-3 w-3", "aria-hidden": "true" }), t('marketplace.detail.installedV', { version: localInstall.version })] }))] }), loc.description && (_jsx("p", { className: "text-sm sm:text-base text-foreground/80 mt-3 max-w-2xl leading-relaxed", children: loc.description }))] }), _jsxs("div", { className: "flex items-center gap-2 shrink-0 self-start", children: [_jsxs(Button, { onClick: primaryAction.onClick, disabled: primaryDisabled, size: "lg", className: "min-w-[8rem]", children: [_jsx(Download, { className: "h-4 w-4 mr-1.5", "aria-hidden": "true" }), primaryAction.label] }), localInstall && (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "outline", size: "lg", className: "px-2.5", "aria-label": t('marketplace.detail.moreOptions'), children: _jsx(MoreHorizontal, { className: "h-4 w-4", "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-56", children: _jsxs(DropdownMenuItem, { onSelect: doUninstallLocal, disabled: installingLocal, className: "text-destructive focus:text-destructive", children: [_jsx(Trash2, { className: "h-4 w-4 mr-2", "aria-hidden": "true" }), t('marketplace.detail.uninstallFromRuntime')] }) })] }))] })] }), localResult && (_jsxs("div", { role: "status", className: `flex items-start gap-2 rounded-md border p-3 text-sm 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.ok ? _jsx(CheckCircle2, { className: "h-4 w-4 mt-0.5 shrink-0", "aria-hidden": "true" }) : _jsx(AlertCircle, { className: "h-4 w-4 mt-0.5 shrink-0", "aria-hidden": "true" }), _jsx("div", { className: "flex-1", children: localResult.message }), _jsx("button", { type: "button", className: "text-xs underline opacity-60 hover:opacity-100", onClick: () => setLocalResult(null), children: t('marketplace.action.dismiss') })] })), _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: t('marketplace.detail.about') }) }), _jsx(CardContent, { children: loc.readme ? (_jsx(MarkdownText, { source: loc.readme })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: t('marketplace.detail.noReadme') })) })] }) }), _jsx("div", { className: "space-y-4", children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: t('marketplace.detail.versions') }) }), _jsx(CardContent, { children: data.versions.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('marketplace.detail.noApprovedVersions') })) : (_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: t('marketplace.detail.prerelease') })] }), _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)
|
|
282
|
+
setInstallResult(null); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t('marketplace.install.dialogTitle', { name: loc.displayName || pkg.manifest_id }) }), _jsx(DialogDescription, { children: getRuntimeConfig().defaultEnvironmentId
|
|
283
|
+
? t('marketplace.install.dialogDescCurrent', { host: typeof window !== 'undefined' ? window.location.host : '' })
|
|
284
|
+
: t('marketplace.install.dialogDescPicker') })] }), 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" }), t('marketplace.action.openOnCloud')] }) })] })) : getRuntimeConfig().defaultEnvironmentId ? (_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: t('marketplace.install.includeSampleData') })] })) : envs.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('marketplace.install.noEnvs') })) : (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "env-select", children: t('marketplace.install.environment') }), _jsxs(Select, { value: selectedEnv, onValueChange: setSelectedEnv, children: [_jsx(SelectTrigger, { id: "env-select", children: _jsx(SelectValue, { placeholder: t('marketplace.install.environmentPlaceholder') }) }), _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: t('marketplace.install.includeSampleData') })] })] })), 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: t('marketplace.action.close') }), !envsError && (_jsx(Button, { onClick: doInstall, disabled: !selectedEnv || installing || installResult?.ok === true, children: installing ? t('marketplace.action.installing') : t('marketplace.action.install') }))] })] }) })] }));
|
|
247
285
|
}
|
|
@@ -11,28 +11,52 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|
|
11
11
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Badge, Input, Button, Skeleton, } from '@object-ui/components';
|
|
12
12
|
import { Package, Search, RefreshCcw, Store, AlertCircle, CheckCircle2, Settings } from 'lucide-react';
|
|
13
13
|
import { useIsWorkspaceAdmin } from '@object-ui/auth';
|
|
14
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
14
15
|
import { PackageIcon } from './PackageIcon';
|
|
15
16
|
import { MarketplaceAccessDenied } from './MarketplaceAccessDenied';
|
|
17
|
+
import { localizePackage } from './usePackageL10n';
|
|
16
18
|
import { listMarketplacePackages, listLocalInstalls, } from './marketplaceApi';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Format a published-at timestamp as a localized relative string.
|
|
21
|
+
*
|
|
22
|
+
* Uses `Intl.RelativeTimeFormat` for proper plural/grammatical rules in
|
|
23
|
+
* the active locale (e.g. "il y a 3 jours", "3 天前"). Falls back to
|
|
24
|
+
* translation keys for environments without RTF support.
|
|
25
|
+
*/
|
|
26
|
+
function useRelativeFormatter() {
|
|
27
|
+
const { language, t } = useObjectTranslation();
|
|
28
|
+
return (iso) => {
|
|
29
|
+
if (!iso)
|
|
30
|
+
return '';
|
|
31
|
+
const d = new Date(iso);
|
|
32
|
+
if (Number.isNaN(d.getTime()))
|
|
33
|
+
return '';
|
|
34
|
+
const days = Math.floor((Date.now() - d.getTime()) / 86400000);
|
|
35
|
+
if (days < 1)
|
|
36
|
+
return t('marketplace.relativeTime.today');
|
|
37
|
+
try {
|
|
38
|
+
const rtf = new Intl.RelativeTimeFormat(language || 'en', { numeric: 'auto' });
|
|
39
|
+
if (days < 30)
|
|
40
|
+
return rtf.format(-days, 'day');
|
|
41
|
+
if (days < 365)
|
|
42
|
+
return rtf.format(-Math.floor(days / 30), 'month');
|
|
43
|
+
return rtf.format(-Math.floor(days / 365), 'year');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
if (days < 30)
|
|
47
|
+
return t('marketplace.relativeTime.daysAgo', { count: days });
|
|
48
|
+
if (days < 365)
|
|
49
|
+
return t('marketplace.relativeTime.monthsAgo', { count: Math.floor(days / 30) });
|
|
50
|
+
return t('marketplace.relativeTime.yearsAgo', { count: Math.floor(days / 365) });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
31
53
|
}
|
|
32
54
|
export function MarketplacePage() {
|
|
33
55
|
const navigate = useNavigate();
|
|
34
56
|
const { appName } = useParams();
|
|
35
57
|
const isAdmin = useIsWorkspaceAdmin();
|
|
58
|
+
const { t, language } = useObjectTranslation();
|
|
59
|
+
const formatRelative = useRelativeFormatter();
|
|
36
60
|
const basePath = appName ? `/apps/${appName}` : '';
|
|
37
61
|
const [items, setItems] = useState([]);
|
|
38
62
|
const [loading, setLoading] = useState(true);
|
|
@@ -74,6 +98,13 @@ export function MarketplacePage() {
|
|
|
74
98
|
}
|
|
75
99
|
return Array.from(s).sort();
|
|
76
100
|
}, [items]);
|
|
101
|
+
// Translate a category enum value via the i18n bundle; fall back to the
|
|
102
|
+
// raw value (lowercased) so unknown publisher categories still render.
|
|
103
|
+
const categoryLabel = (cat) => {
|
|
104
|
+
const key = `marketplace.category.${cat}`;
|
|
105
|
+
const translated = t(key, { defaultValue: cat });
|
|
106
|
+
return translated === key ? cat : translated;
|
|
107
|
+
};
|
|
77
108
|
const filtered = useMemo(() => {
|
|
78
109
|
const q = query.trim().toLowerCase();
|
|
79
110
|
return items.filter((it) => {
|
|
@@ -81,19 +112,25 @@ export function MarketplacePage() {
|
|
|
81
112
|
return false;
|
|
82
113
|
if (!q)
|
|
83
114
|
return true;
|
|
84
|
-
const
|
|
115
|
+
const loc = localizePackage(it, language);
|
|
116
|
+
const hay = `${loc.displayName} ${it.manifest_id} ${loc.description ?? ''}`.toLowerCase();
|
|
85
117
|
return hay.includes(q);
|
|
86
118
|
});
|
|
87
|
-
}, [items, query, category]);
|
|
119
|
+
}, [items, query, category, language]);
|
|
88
120
|
if (!isAdmin)
|
|
89
121
|
return _jsx(MarketplaceAccessDenied, {});
|
|
90
|
-
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:
|
|
122
|
+
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: t('marketplace.title') })] }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: t('marketplace.subtitle') })] }), _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.length > 0
|
|
123
|
+
? t('marketplace.installedCount', { count: installed.length })
|
|
124
|
+
: t('marketplace.installed')] }), _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" }), t('marketplace.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: t('marketplace.searchPlaceholder'), className: "pl-9", "aria-label": t('marketplace.searchAria') })] }), categories.length > 0 && (_jsxs("div", { className: "flex flex-wrap gap-1.5", children: [_jsx(Button, { size: "sm", variant: category === '' ? 'default' : 'outline', onClick: () => setCategory(''), children: t('marketplace.all') }), categories.map((cat) => (_jsx(Button, { size: "sm", variant: category === cat ? 'default' : 'outline', onClick: () => setCategory(cat), children: categoryLabel(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: t('marketplace.load.failed') }), _jsx("div", { className: "text-muted-foreground mt-1", children: error }), _jsx("div", { className: "text-xs text-muted-foreground mt-2",
|
|
125
|
+
// Hint message contains an inline <code> tag — render translated HTML safely from our own bundle.
|
|
126
|
+
dangerouslySetInnerHTML: { __html: t('marketplace.load.failedHint') } })] })] })), 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 ? t('marketplace.noApprovedYet') : t('marketplace.noMatchFilters') })) : (_jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3", children: filtered.map((pkg) => {
|
|
91
127
|
const localEntry = installedByManifestId.get(pkg.manifest_id);
|
|
128
|
+
const loc = localizePackage(pkg, language);
|
|
92
129
|
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) => {
|
|
93
130
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
94
131
|
e.preventDefault();
|
|
95
132
|
navigate(`${basePath}/system/marketplace/${pkg.id}`);
|
|
96
133
|
}
|
|
97
|
-
}, "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:
|
|
134
|
+
}, "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: loc.displayName, manifestId: pkg.manifest_id }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx(CardTitle, { className: "text-base truncate", children: loc.displayName }), _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: loc.description || t('marketplace.noDescription') }), _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" }), t('marketplace.installedBadge', { version: 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" }), t('marketplace.versionBadge', { version: pkg.latest_version.version })] })), pkg.category && (_jsx(Badge, { variant: "outline", className: "text-xs", children: categoryLabel(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));
|
|
98
135
|
}) }))] }));
|
|
99
136
|
}
|
|
@@ -12,6 +12,20 @@
|
|
|
12
12
|
* cloud/packages/service-cloud/src/routes/marketplace.ts
|
|
13
13
|
* framework/packages/runtime/src/cloud/marketplace-proxy-plugin.ts
|
|
14
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* Per-locale overrides for translatable package fields. Mirrors
|
|
17
|
+
* `PackageTranslation` from @objectstack/spec/cloud — duplicated here
|
|
18
|
+
* to avoid pulling the spec package into the app-shell bundle.
|
|
19
|
+
* See framework/packages/spec/src/cloud/package.zod.ts.
|
|
20
|
+
*/
|
|
21
|
+
export interface MarketplacePackageTranslation {
|
|
22
|
+
displayName?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
readme?: string;
|
|
25
|
+
tagline?: string;
|
|
26
|
+
screenshotCaptions?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export type MarketplacePackageTranslations = Record<string, MarketplacePackageTranslation>;
|
|
15
29
|
export interface MarketplacePackageSummary {
|
|
16
30
|
id: string;
|
|
17
31
|
manifest_id: string;
|
|
@@ -26,6 +40,12 @@ export interface MarketplacePackageSummary {
|
|
|
26
40
|
is_starter?: boolean;
|
|
27
41
|
created_at?: string;
|
|
28
42
|
updated_at?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Locale-keyed overrides for display_name / description / readme.
|
|
45
|
+
* Optional on the wire: older control planes / older runtimes may not
|
|
46
|
+
* project this column yet. UI should fall back to the base field.
|
|
47
|
+
*/
|
|
48
|
+
translations?: MarketplacePackageTranslations | null;
|
|
29
49
|
latest_version: MarketplacePackageVersion | null;
|
|
30
50
|
}
|
|
31
51
|
export interface MarketplacePackageDetail extends MarketplacePackageSummary {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePackageL10n
|
|
3
|
+
*
|
|
4
|
+
* Resolves a marketplace package's translatable fields against the
|
|
5
|
+
* current i18n locale. Mirrors the server-side resolver
|
|
6
|
+
* `resolvePackageL10n` in @objectstack/spec/cloud but stays inline here
|
|
7
|
+
* so the app-shell doesn't pull the full spec package into its bundle.
|
|
8
|
+
*
|
|
9
|
+
* Resolution chain (first hit wins):
|
|
10
|
+
* 1. translations[<exact requested locale>] (e.g. `zh-CN`)
|
|
11
|
+
* 2. translations[<language-only locale>] (e.g. `zh`)
|
|
12
|
+
* 3. translations[<fallback locale>] (default `en`)
|
|
13
|
+
* 4. base column on the package row (snake_case from REST)
|
|
14
|
+
*/
|
|
15
|
+
import type { MarketplacePackageSummary } from './marketplaceApi';
|
|
16
|
+
export interface LocalizedPackage {
|
|
17
|
+
displayName: string;
|
|
18
|
+
description: string | undefined;
|
|
19
|
+
readme: string | undefined;
|
|
20
|
+
tagline: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve `displayName`, `description`, `readme`, and `tagline` for a
|
|
24
|
+
* package against the active locale. `displayName` is guaranteed to be
|
|
25
|
+
* a non-empty string (falls back to `manifest_id` as a last resort) so
|
|
26
|
+
* callers can render it without further guards.
|
|
27
|
+
*/
|
|
28
|
+
export declare function usePackageL10n(pkg: Pick<MarketplacePackageSummary, 'manifest_id' | 'display_name' | 'translations'> & {
|
|
29
|
+
description?: string | null;
|
|
30
|
+
readme?: string | null;
|
|
31
|
+
} | null | undefined): LocalizedPackage;
|
|
32
|
+
/**
|
|
33
|
+
* Stateless variant for use in `.map()` over a list of packages where
|
|
34
|
+
* calling `usePackageL10n` per row would violate the rules of hooks.
|
|
35
|
+
* The caller passes the language they obtained once from
|
|
36
|
+
* `useObjectTranslation()` at the top of the component.
|
|
37
|
+
*/
|
|
38
|
+
export declare function localizePackage(pkg: MarketplacePackageSummary, language: string): LocalizedPackage;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePackageL10n
|
|
3
|
+
*
|
|
4
|
+
* Resolves a marketplace package's translatable fields against the
|
|
5
|
+
* current i18n locale. Mirrors the server-side resolver
|
|
6
|
+
* `resolvePackageL10n` in @objectstack/spec/cloud but stays inline here
|
|
7
|
+
* so the app-shell doesn't pull the full spec package into its bundle.
|
|
8
|
+
*
|
|
9
|
+
* Resolution chain (first hit wins):
|
|
10
|
+
* 1. translations[<exact requested locale>] (e.g. `zh-CN`)
|
|
11
|
+
* 2. translations[<language-only locale>] (e.g. `zh`)
|
|
12
|
+
* 3. translations[<fallback locale>] (default `en`)
|
|
13
|
+
* 4. base column on the package row (snake_case from REST)
|
|
14
|
+
*/
|
|
15
|
+
import { useMemo } from 'react';
|
|
16
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
17
|
+
const FALLBACK_LOCALE = 'en';
|
|
18
|
+
function languageOf(locale) {
|
|
19
|
+
const dash = locale.indexOf('-');
|
|
20
|
+
return dash === -1 ? locale : locale.slice(0, dash);
|
|
21
|
+
}
|
|
22
|
+
function uniqueLocales(codes) {
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const c of codes) {
|
|
26
|
+
if (!c || seen.has(c))
|
|
27
|
+
continue;
|
|
28
|
+
seen.add(c);
|
|
29
|
+
out.push(c);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function pickFromTranslations(translations, locale, field) {
|
|
34
|
+
if (!translations)
|
|
35
|
+
return undefined;
|
|
36
|
+
const lang = languageOf(locale);
|
|
37
|
+
// Build chain: exact → language-only → any regional variant matching the
|
|
38
|
+
// language (e.g. 'zh' → 'zh-CN' / 'zh-Hans') → fallback. The regional
|
|
39
|
+
// expansion covers the common case where the i18n provider returns a
|
|
40
|
+
// bare language code ('zh') but package manifests ship region-tagged
|
|
41
|
+
// translations ('zh-CN').
|
|
42
|
+
const variants = Object.keys(translations).filter((code) => code === lang || code.startsWith(`${lang}-`));
|
|
43
|
+
const chain = uniqueLocales([locale, lang, ...variants, FALLBACK_LOCALE]);
|
|
44
|
+
for (const code of chain) {
|
|
45
|
+
const entry = translations[code];
|
|
46
|
+
if (!entry)
|
|
47
|
+
continue;
|
|
48
|
+
const value = entry[field];
|
|
49
|
+
if (typeof value === 'string' && value.length > 0)
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
function baseColumn(pkg, field) {
|
|
55
|
+
// Map camelCase L10n field → REST snake_case column.
|
|
56
|
+
const SNAKE = {
|
|
57
|
+
displayName: 'display_name',
|
|
58
|
+
description: 'description',
|
|
59
|
+
readme: 'readme',
|
|
60
|
+
tagline: 'tagline',
|
|
61
|
+
// Captions live only inside translations on the wire — no base column.
|
|
62
|
+
screenshotCaptions: 'screenshotCaptions',
|
|
63
|
+
};
|
|
64
|
+
const v = pkg[SNAKE[field]] ?? pkg[field];
|
|
65
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve `displayName`, `description`, `readme`, and `tagline` for a
|
|
69
|
+
* package against the active locale. `displayName` is guaranteed to be
|
|
70
|
+
* a non-empty string (falls back to `manifest_id` as a last resort) so
|
|
71
|
+
* callers can render it without further guards.
|
|
72
|
+
*/
|
|
73
|
+
export function usePackageL10n(pkg) {
|
|
74
|
+
const { language } = useObjectTranslation();
|
|
75
|
+
return useMemo(() => {
|
|
76
|
+
if (!pkg) {
|
|
77
|
+
return { displayName: '', description: undefined, readme: undefined, tagline: undefined };
|
|
78
|
+
}
|
|
79
|
+
const locale = language || FALLBACK_LOCALE;
|
|
80
|
+
const displayName = pickFromTranslations(pkg.translations ?? undefined, locale, 'displayName')
|
|
81
|
+
?? baseColumn(pkg, 'displayName')
|
|
82
|
+
?? pkg.manifest_id;
|
|
83
|
+
const description = pickFromTranslations(pkg.translations ?? undefined, locale, 'description')
|
|
84
|
+
?? baseColumn(pkg, 'description');
|
|
85
|
+
const readme = pickFromTranslations(pkg.translations ?? undefined, locale, 'readme')
|
|
86
|
+
?? baseColumn(pkg, 'readme');
|
|
87
|
+
const tagline = pickFromTranslations(pkg.translations ?? undefined, locale, 'tagline')
|
|
88
|
+
?? baseColumn(pkg, 'tagline');
|
|
89
|
+
return { displayName, description, readme, tagline };
|
|
90
|
+
}, [pkg, language]);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Stateless variant for use in `.map()` over a list of packages where
|
|
94
|
+
* calling `usePackageL10n` per row would violate the rules of hooks.
|
|
95
|
+
* The caller passes the language they obtained once from
|
|
96
|
+
* `useObjectTranslation()` at the top of the component.
|
|
97
|
+
*/
|
|
98
|
+
export function localizePackage(pkg, language) {
|
|
99
|
+
const locale = language || FALLBACK_LOCALE;
|
|
100
|
+
const displayName = pickFromTranslations(pkg.translations ?? undefined, locale, 'displayName')
|
|
101
|
+
?? baseColumn(pkg, 'displayName')
|
|
102
|
+
?? pkg.manifest_id;
|
|
103
|
+
const description = pickFromTranslations(pkg.translations ?? undefined, locale, 'description')
|
|
104
|
+
?? baseColumn(pkg, 'description');
|
|
105
|
+
const readme = pickFromTranslations(pkg.translations ?? undefined, locale, 'readme')
|
|
106
|
+
?? baseColumn(pkg, 'readme');
|
|
107
|
+
const tagline = pickFromTranslations(pkg.translations ?? undefined, locale, 'tagline')
|
|
108
|
+
?? baseColumn(pkg, 'tagline');
|
|
109
|
+
return { displayName, description, readme, tagline };
|
|
110
|
+
}
|