@burdenoff/microfe-devportal 2026.1112.2 → 2026.1113.2
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/dist/devportal/DevportalRoot.js +65 -119
- package/dist/devportal/DevportalRoot.js.map +1 -1
- package/dist/devportal/components/AppCard.js +72 -69
- package/dist/devportal/components/AppCard.js.map +1 -1
- package/dist/devportal/components/DeveloperSettingsHub.d.ts +6 -0
- package/dist/devportal/components/DeveloperSettingsHub.js +59 -0
- package/dist/devportal/components/DeveloperSettingsHub.js.map +1 -0
- package/dist/devportal/components/ImageUploadField.js +82 -80
- package/dist/devportal/components/ImageUploadField.js.map +1 -1
- package/dist/devportal/components/SectionNav.d.ts +1 -1
- package/dist/devportal/components/SectionNav.js +60 -36
- package/dist/devportal/components/SectionNav.js.map +1 -1
- package/dist/devportal/components/StoreImage.d.ts +15 -0
- package/dist/devportal/components/StoreImage.js +30 -0
- package/dist/devportal/components/StoreImage.js.map +1 -0
- package/dist/devportal/components/index.d.ts +1 -0
- package/dist/devportal/components/index.js +17 -16
- package/dist/devportal/hooks/usePackageUpload.d.ts +46 -4
- package/dist/devportal/hooks/usePackageUpload.js +90 -36
- package/dist/devportal/hooks/usePackageUpload.js.map +1 -1
- package/dist/devportal/pages/AdminReviewPage.js +205 -202
- package/dist/devportal/pages/AdminReviewPage.js.map +1 -1
- package/dist/devportal/pages/AnalyticsDashboardPage.js +186 -179
- package/dist/devportal/pages/AnalyticsDashboardPage.js.map +1 -1
- package/dist/devportal/pages/ApiKeysPage.js +113 -111
- package/dist/devportal/pages/ApiKeysPage.js.map +1 -1
- package/dist/devportal/pages/AppLogsPage.js +80 -84
- package/dist/devportal/pages/AppLogsPage.js.map +1 -1
- package/dist/devportal/pages/ApplicationDetailPage.js +529 -504
- package/dist/devportal/pages/ApplicationDetailPage.js.map +1 -1
- package/dist/devportal/pages/ApplicationsPage.js +186 -183
- package/dist/devportal/pages/ApplicationsPage.js.map +1 -1
- package/dist/devportal/pages/CreateApiKeyPage.js +163 -166
- package/dist/devportal/pages/CreateApiKeyPage.js.map +1 -1
- package/dist/devportal/pages/CreateOAuthClientPage.js +134 -130
- package/dist/devportal/pages/CreateOAuthClientPage.js.map +1 -1
- package/dist/devportal/pages/CreatePhysicalItemPage.js +2 -2
- package/dist/devportal/pages/CreatePhysicalItemPage.js.map +1 -1
- package/dist/devportal/pages/DelegationsPage.js +11 -11
- package/dist/devportal/pages/DocumentationPage.js +100 -94
- package/dist/devportal/pages/DocumentationPage.js.map +1 -1
- package/dist/devportal/pages/EditApplicationPage.js +379 -377
- package/dist/devportal/pages/EditApplicationPage.js.map +1 -1
- package/dist/devportal/pages/EditPhysicalItemPage.js +312 -307
- package/dist/devportal/pages/EditPhysicalItemPage.js.map +1 -1
- package/dist/devportal/pages/InvitationDecisionPage.js +83 -79
- package/dist/devportal/pages/InvitationDecisionPage.js.map +1 -1
- package/dist/devportal/pages/ListingPreviewPage.js +175 -171
- package/dist/devportal/pages/ListingPreviewPage.js.map +1 -1
- package/dist/devportal/pages/OAuthClientDetailPage.js +76 -76
- package/dist/devportal/pages/OAuthClientDetailPage.js.map +1 -1
- package/dist/devportal/pages/OAuthClientsPage.js +126 -124
- package/dist/devportal/pages/OAuthClientsPage.js.map +1 -1
- package/dist/devportal/pages/OrganizationDetailPage.js +19 -19
- package/dist/devportal/pages/OrganizationMembersPage.js +223 -204
- package/dist/devportal/pages/OrganizationMembersPage.js.map +1 -1
- package/dist/devportal/pages/ResourcesPage.d.ts +6 -0
- package/dist/devportal/pages/ResourcesPage.js +171 -155
- package/dist/devportal/pages/ResourcesPage.js.map +1 -1
- package/dist/devportal/pages/SellerDashboardPage.js +117 -115
- package/dist/devportal/pages/SellerDashboardPage.js.map +1 -1
- package/dist/devportal/pages/SellerItemsPage.js +202 -199
- package/dist/devportal/pages/SellerItemsPage.js.map +1 -1
- package/dist/devportal/pages/SettingsPage.js +330 -315
- package/dist/devportal/pages/SettingsPage.js.map +1 -1
- package/dist/devportal/pages/SetupPage.js +5 -5
- package/dist/devportal/pages/StoreSubmissionPage.js +133 -131
- package/dist/devportal/pages/StoreSubmissionPage.js.map +1 -1
- package/dist/devportal/pages/WebhookDetailPage.js +6 -6
- package/dist/devportal/pages/WebhooksPage.js +39 -39
- package/dist/devportal/pages/WebhooksPage.js.map +1 -1
- package/dist/devportal/utils/appDetailTabs.d.ts +73 -0
- package/dist/devportal/utils/appDetailTabs.js +62 -0
- package/dist/devportal/utils/appDetailTabs.js.map +1 -0
- package/dist/devportal/utils/developerNav.d.ts +39 -0
- package/dist/devportal/utils/developerNav.js +304 -0
- package/dist/devportal/utils/developerNav.js.map +1 -0
- package/package.json +2 -2
|
@@ -1,56 +1,57 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { StoreImage as e, StoreImagePlaceholder as t } from "./StoreImage.js";
|
|
2
|
+
import { cn as n } from "../utils/cn.js";
|
|
3
|
+
import { useCallback as r, useRef as i, useState as a } from "react";
|
|
4
|
+
import { Loader2 as o, Upload as s, X as c } from "lucide-react";
|
|
5
|
+
import { Fragment as l, jsx as u, jsxs as d } from "react/jsx-runtime";
|
|
5
6
|
// src/devportal/components/ImageUploadField.tsx
|
|
6
|
-
var
|
|
7
|
-
let v =
|
|
8
|
-
x.current !==
|
|
9
|
-
let S =
|
|
7
|
+
var f = ({ label: e, value: t, onChange: c, hint: l, accept: f = "image/png,image/jpeg,image/webp,image/gif,image/svg+xml", aspectClass: p = "aspect-square", upload: m, uploading: h, progress: g, error: _ }) => {
|
|
8
|
+
let v = i(null), [y, b] = a(!1), x = i(t);
|
|
9
|
+
x.current !== t && (x.current = t, y && b(!1));
|
|
10
|
+
let S = r(async (e) => {
|
|
10
11
|
try {
|
|
11
|
-
|
|
12
|
+
c((await m(e)).downloadUrl);
|
|
12
13
|
} catch {}
|
|
13
|
-
}, [m,
|
|
14
|
-
return /* @__PURE__ */
|
|
14
|
+
}, [m, c]);
|
|
15
|
+
return /* @__PURE__ */ d("div", {
|
|
15
16
|
className: "space-y-1.5",
|
|
16
17
|
children: [
|
|
17
|
-
/* @__PURE__ */
|
|
18
|
+
/* @__PURE__ */ u("label", {
|
|
18
19
|
className: "text-sm font-medium",
|
|
19
|
-
children:
|
|
20
|
+
children: e
|
|
20
21
|
}),
|
|
21
|
-
/* @__PURE__ */
|
|
22
|
+
/* @__PURE__ */ d("div", {
|
|
22
23
|
className: "flex gap-3 items-start",
|
|
23
|
-
children: [/* @__PURE__ */
|
|
24
|
-
className:
|
|
25
|
-
children: [
|
|
26
|
-
src:
|
|
27
|
-
alt:
|
|
24
|
+
children: [/* @__PURE__ */ d("div", {
|
|
25
|
+
className: n("relative shrink-0 w-20 rounded-lg border border-border-subtle bg-bg-sunken overflow-hidden flex items-center justify-center", p),
|
|
26
|
+
children: [t && !y ? /* @__PURE__ */ u("img", {
|
|
27
|
+
src: t,
|
|
28
|
+
alt: e,
|
|
28
29
|
className: "w-full h-full object-cover",
|
|
29
30
|
onError: () => b(!0)
|
|
30
|
-
}) : /* @__PURE__ */
|
|
31
|
+
}) : /* @__PURE__ */ u(s, {
|
|
31
32
|
size: 18,
|
|
32
33
|
className: "text-text-secondary opacity-40"
|
|
33
|
-
}), h && /* @__PURE__ */
|
|
34
|
+
}), h && /* @__PURE__ */ d("div", {
|
|
34
35
|
className: "absolute inset-0 bg-bg-overlay flex flex-col items-center justify-center gap-1",
|
|
35
|
-
children: [/* @__PURE__ */
|
|
36
|
+
children: [/* @__PURE__ */ u(o, {
|
|
36
37
|
size: 14,
|
|
37
38
|
className: "animate-spin text-white"
|
|
38
|
-
}), /* @__PURE__ */
|
|
39
|
+
}), /* @__PURE__ */ d("span", {
|
|
39
40
|
className: "text-white text-xs",
|
|
40
41
|
children: [g, "%"]
|
|
41
42
|
})]
|
|
42
43
|
})]
|
|
43
|
-
}), /* @__PURE__ */
|
|
44
|
+
}), /* @__PURE__ */ d("div", {
|
|
44
45
|
className: "flex-1 space-y-1.5",
|
|
45
46
|
children: [
|
|
46
|
-
/* @__PURE__ */
|
|
47
|
+
/* @__PURE__ */ u("input", {
|
|
47
48
|
type: "url",
|
|
48
|
-
value:
|
|
49
|
-
onChange: (e) =>
|
|
49
|
+
value: t,
|
|
50
|
+
onChange: (e) => c(e.target.value),
|
|
50
51
|
placeholder: "https://...",
|
|
51
52
|
className: "w-full rounded-md border border-border-subtle bg-bg-input px-3 py-1.5 text-sm placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-primary/30"
|
|
52
53
|
}),
|
|
53
|
-
/* @__PURE__ */
|
|
54
|
+
/* @__PURE__ */ u("input", {
|
|
54
55
|
ref: v,
|
|
55
56
|
type: "file",
|
|
56
57
|
accept: f,
|
|
@@ -60,122 +61,123 @@ var u = ({ label: o, value: s, onChange: u, hint: d, accept: f = "image/png,imag
|
|
|
60
61
|
t && S(t), e.target.value = "";
|
|
61
62
|
}
|
|
62
63
|
}),
|
|
63
|
-
/* @__PURE__ */
|
|
64
|
+
/* @__PURE__ */ d("button", {
|
|
64
65
|
type: "button",
|
|
65
66
|
disabled: h,
|
|
66
67
|
onClick: () => v.current?.click(),
|
|
67
68
|
className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium hover:bg-bg-sunken transition-colors disabled:opacity-50",
|
|
68
|
-
children: [/* @__PURE__ */
|
|
69
|
+
children: [/* @__PURE__ */ u(s, { size: 11 }), h ? `Uploading… ${g}%` : "Upload image"]
|
|
69
70
|
}),
|
|
70
|
-
_ && /* @__PURE__ */
|
|
71
|
+
_ && /* @__PURE__ */ u("p", {
|
|
71
72
|
className: "text-xs text-status-error-text",
|
|
72
73
|
children: _
|
|
73
74
|
})
|
|
74
75
|
]
|
|
75
76
|
})]
|
|
76
77
|
}),
|
|
77
|
-
|
|
78
|
+
l && /* @__PURE__ */ u("p", {
|
|
78
79
|
className: "text-xs text-text-secondary",
|
|
79
|
-
children:
|
|
80
|
+
children: l
|
|
80
81
|
})
|
|
81
82
|
]
|
|
82
83
|
});
|
|
83
|
-
},
|
|
84
|
-
let
|
|
84
|
+
}, p = ({ urls: f, onChange: p, upload: m, uploading: h, progress: g, error: _ }) => {
|
|
85
|
+
let v = i(null), [y, b] = a(""), x = r(async (e) => {
|
|
85
86
|
try {
|
|
86
|
-
let t = await
|
|
87
|
-
|
|
87
|
+
let t = await m(e);
|
|
88
|
+
p([...f, t.downloadUrl]);
|
|
88
89
|
} catch {}
|
|
89
90
|
}, [
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
]),
|
|
94
|
-
let e =
|
|
95
|
-
e && !
|
|
96
|
-
},
|
|
97
|
-
|
|
91
|
+
m,
|
|
92
|
+
p,
|
|
93
|
+
f
|
|
94
|
+
]), S = () => {
|
|
95
|
+
let e = y.trim();
|
|
96
|
+
e && !f.includes(e) && (p([...f, e]), b(""));
|
|
97
|
+
}, C = (e) => {
|
|
98
|
+
p(f.filter((t, n) => n !== e));
|
|
98
99
|
};
|
|
99
|
-
return /* @__PURE__ */
|
|
100
|
+
return /* @__PURE__ */ d("div", {
|
|
100
101
|
className: "space-y-2",
|
|
101
102
|
children: [
|
|
102
|
-
/* @__PURE__ */
|
|
103
|
+
/* @__PURE__ */ d("div", {
|
|
103
104
|
className: "flex flex-col gap-0.5",
|
|
104
|
-
children: [/* @__PURE__ */
|
|
105
|
+
children: [/* @__PURE__ */ u("label", {
|
|
105
106
|
className: "text-sm font-medium",
|
|
106
107
|
children: "Screenshots"
|
|
107
|
-
}), /* @__PURE__ */
|
|
108
|
+
}), /* @__PURE__ */ u("p", {
|
|
108
109
|
className: "text-xs text-text-muted",
|
|
109
110
|
children: "Shown in the horizontal preview strip below the hero — wide landscape proportions work best (16:9)"
|
|
110
111
|
})]
|
|
111
112
|
}),
|
|
112
|
-
/* @__PURE__ */
|
|
113
|
+
/* @__PURE__ */ d("div", {
|
|
113
114
|
className: "flex flex-wrap gap-3",
|
|
114
|
-
children: [
|
|
115
|
+
children: [f.map((n, r) => /* @__PURE__ */ d("div", {
|
|
115
116
|
className: "relative group w-36 aspect-video rounded-lg border border-border-subtle overflow-hidden bg-bg-sunken",
|
|
116
|
-
children: [/* @__PURE__ */
|
|
117
|
-
src:
|
|
118
|
-
alt: `Screenshot ${
|
|
119
|
-
className: "w-full h-full object-cover"
|
|
120
|
-
|
|
117
|
+
children: [/* @__PURE__ */ u(e, {
|
|
118
|
+
src: n,
|
|
119
|
+
alt: `Screenshot ${r + 1}`,
|
|
120
|
+
className: "w-full h-full object-cover",
|
|
121
|
+
fallback: /* @__PURE__ */ u(t, { className: "h-full w-full rounded-none border-0" })
|
|
122
|
+
}), /* @__PURE__ */ u("button", {
|
|
121
123
|
type: "button",
|
|
122
|
-
onClick: () =>
|
|
124
|
+
onClick: () => C(r),
|
|
123
125
|
className: "absolute top-1 right-1 rounded-full bg-bg-overlay text-white p-0.5 opacity-0 group-hover:opacity-100 transition-opacity",
|
|
124
|
-
children: /* @__PURE__ */ c
|
|
126
|
+
children: /* @__PURE__ */ u(c, { size: 12 })
|
|
125
127
|
})]
|
|
126
|
-
},
|
|
128
|
+
}, r)), /* @__PURE__ */ u("button", {
|
|
127
129
|
type: "button",
|
|
128
|
-
disabled:
|
|
129
|
-
onClick: () =>
|
|
130
|
-
className:
|
|
131
|
-
children:
|
|
130
|
+
disabled: h,
|
|
131
|
+
onClick: () => v.current?.click(),
|
|
132
|
+
className: n("w-36 aspect-video rounded-lg border-2 border-dashed border-border-subtle flex flex-col items-center justify-center gap-1.5 text-text-secondary hover:border-border-strong hover:bg-bg-sunken transition-colors disabled:opacity-50"),
|
|
133
|
+
children: h ? /* @__PURE__ */ d(l, { children: [/* @__PURE__ */ u(o, {
|
|
132
134
|
size: 18,
|
|
133
135
|
className: "animate-spin text-primary"
|
|
134
|
-
}), /* @__PURE__ */
|
|
136
|
+
}), /* @__PURE__ */ d("span", {
|
|
135
137
|
className: "text-xs",
|
|
136
|
-
children: [
|
|
137
|
-
})] }) : /* @__PURE__ */ l
|
|
138
|
+
children: [g, "%"]
|
|
139
|
+
})] }) : /* @__PURE__ */ d(l, { children: [/* @__PURE__ */ u(s, { size: 18 }), /* @__PURE__ */ u("span", {
|
|
138
140
|
className: "text-xs",
|
|
139
141
|
children: "Add screenshot"
|
|
140
142
|
})] })
|
|
141
143
|
})]
|
|
142
144
|
}),
|
|
143
|
-
/* @__PURE__ */
|
|
145
|
+
/* @__PURE__ */ d("div", {
|
|
144
146
|
className: "flex gap-2",
|
|
145
|
-
children: [/* @__PURE__ */
|
|
147
|
+
children: [/* @__PURE__ */ u("input", {
|
|
146
148
|
type: "url",
|
|
147
|
-
value:
|
|
148
|
-
onChange: (e) =>
|
|
149
|
+
value: y,
|
|
150
|
+
onChange: (e) => b(e.target.value),
|
|
149
151
|
onKeyDown: (e) => {
|
|
150
|
-
e.key === "Enter" && (e.preventDefault(),
|
|
152
|
+
e.key === "Enter" && (e.preventDefault(), S());
|
|
151
153
|
},
|
|
152
154
|
placeholder: "https://... paste a screenshot URL",
|
|
153
155
|
className: "flex-1 rounded-md border border-border-subtle bg-bg-input px-3 py-1.5 text-sm placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-primary/30"
|
|
154
|
-
}), /* @__PURE__ */
|
|
156
|
+
}), /* @__PURE__ */ u("button", {
|
|
155
157
|
type: "button",
|
|
156
|
-
onClick:
|
|
157
|
-
disabled: !
|
|
158
|
+
onClick: S,
|
|
159
|
+
disabled: !y.trim(),
|
|
158
160
|
className: "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium hover:bg-bg-sunken transition-colors disabled:opacity-50",
|
|
159
161
|
children: "Add"
|
|
160
162
|
})]
|
|
161
163
|
}),
|
|
162
|
-
/* @__PURE__ */
|
|
163
|
-
ref:
|
|
164
|
+
/* @__PURE__ */ u("input", {
|
|
165
|
+
ref: v,
|
|
164
166
|
type: "file",
|
|
165
167
|
accept: "image/png,image/jpeg,image/webp,image/gif",
|
|
166
168
|
className: "hidden",
|
|
167
169
|
onChange: (e) => {
|
|
168
170
|
let t = e.target.files?.[0];
|
|
169
|
-
t &&
|
|
171
|
+
t && x(t), e.target.value = "";
|
|
170
172
|
}
|
|
171
173
|
}),
|
|
172
|
-
|
|
174
|
+
_ && /* @__PURE__ */ u("p", {
|
|
173
175
|
className: "text-xs text-status-error-text",
|
|
174
|
-
children:
|
|
176
|
+
children: _
|
|
175
177
|
})
|
|
176
178
|
]
|
|
177
179
|
});
|
|
178
180
|
};
|
|
179
|
-
export {
|
|
181
|
+
export { f as ImageUploadField, p as ScreenshotGallery };
|
|
180
182
|
|
|
181
183
|
//# sourceMappingURL=ImageUploadField.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ImageUploadField.js","names":["_Fragment","ImageUploadField","inputRef","useRef","imgBroken","setImgBroken","useState","prevValue","value","handleFile","useCallback","upload","file","onChange","label","cn","aspectClass","Upload","uploading","Loader2","progress","f","error","hint","ScreenshotGallery","urlInput","setUrlInput","result","urls","addUrl","trimmed","remove","i","idx","url","X","Fragment"],"sources":["../../../src/devportal/components/ImageUploadField.tsx"],"sourcesContent":["import type { FC } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\nimport { Loader2, Upload, X } from \"lucide-react\";\nimport { cn } from \"../utils/cn\";\n\n// =============================================================================\n// IMAGE UPLOAD COMPONENTS\n//\n// Shared by the devportal App pages (Store Listing tab) and the physical\n// item create/edit pages — a single icon/banner upload field plus a\n// screenshot gallery, both backed by usePackageUpload's\n// upload(file) => Promise<{ downloadUrl }> shape.\n// =============================================================================\n\ninterface ImageUploadFieldProps {\n label: string;\n value: string;\n onChange: (url: string) => void;\n hint?: string;\n accept?: string;\n aspectClass?: string; // tailwind class for the preview container ratio\n upload: (file: File) => Promise<{ downloadUrl: string }>;\n uploading: boolean;\n progress: number;\n error: string | null;\n}\n\nexport const ImageUploadField: FC<ImageUploadFieldProps> = ({\n label,\n value,\n onChange,\n hint,\n accept = \"image/png,image/jpeg,image/webp,image/gif,image/svg+xml\",\n aspectClass = \"aspect-square\",\n upload,\n uploading,\n progress,\n error,\n}) => {\n const inputRef = useRef<HTMLInputElement>(null);\n const [imgBroken, setImgBroken] = useState(false);\n const prevValue = useRef(value);\n if (prevValue.current !== value) {\n prevValue.current = value;\n if (imgBroken) setImgBroken(false);\n }\n\n const handleFile = useCallback(\n async (file: File) => {\n try {\n const result = await upload(file);\n onChange(result.downloadUrl);\n } catch {\n // error shown via error prop\n }\n },\n [upload, onChange],\n );\n\n return (\n <div className=\"space-y-1.5\">\n <label className=\"text-sm font-medium\">{label}</label>\n <div className=\"flex gap-3 items-start\">\n {/* Preview */}\n <div\n className={cn(\n \"relative shrink-0 w-20 rounded-lg border border-border-subtle bg-bg-sunken overflow-hidden flex items-center justify-center\",\n aspectClass,\n )}\n >\n {value && !imgBroken ? (\n <img\n src={value}\n alt={label}\n className=\"w-full h-full object-cover\"\n onError={() => setImgBroken(true)}\n />\n ) : (\n <Upload size={18} className=\"text-text-secondary opacity-40\" />\n )}\n {uploading && (\n <div className=\"absolute inset-0 bg-bg-overlay flex flex-col items-center justify-center gap-1\">\n <Loader2 size={14} className=\"animate-spin text-white\" />\n <span className=\"text-white text-xs\">{progress}%</span>\n </div>\n )}\n </div>\n {/* URL input + upload button */}\n <div className=\"flex-1 space-y-1.5\">\n <input\n type=\"url\"\n value={value}\n onChange={(e) => onChange(e.target.value)}\n placeholder=\"https://...\"\n className=\"w-full rounded-md border border-border-subtle bg-bg-input px-3 py-1.5 text-sm placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-primary/30\"\n />\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n className=\"hidden\"\n onChange={(e) => {\n const f = e.target.files?.[0];\n if (f) void handleFile(f);\n e.target.value = \"\";\n }}\n />\n <button\n type=\"button\"\n disabled={uploading}\n onClick={() => inputRef.current?.click()}\n className=\"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium hover:bg-bg-sunken transition-colors disabled:opacity-50\"\n >\n <Upload size={11} />\n {uploading ? `Uploading… ${progress}%` : \"Upload image\"}\n </button>\n {error && <p className=\"text-xs text-status-error-text\">{error}</p>}\n </div>\n </div>\n {hint && <p className=\"text-xs text-text-secondary\">{hint}</p>}\n </div>\n );\n};\n\ninterface ScreenshotGalleryProps {\n urls: string[];\n onChange: (urls: string[]) => void;\n upload: (file: File) => Promise<{ downloadUrl: string }>;\n uploading: boolean;\n progress: number;\n error: string | null;\n}\n\nexport const ScreenshotGallery: FC<ScreenshotGalleryProps> = ({\n urls,\n onChange,\n upload,\n uploading,\n progress,\n error,\n}) => {\n const inputRef = useRef<HTMLInputElement>(null);\n const [urlInput, setUrlInput] = useState(\"\");\n\n const handleFile = useCallback(\n async (file: File) => {\n try {\n const result = await upload(file);\n onChange([...urls, result.downloadUrl]);\n } catch {\n // error shown via error prop\n }\n },\n [upload, onChange, urls],\n );\n\n const addUrl = () => {\n const trimmed = urlInput.trim();\n if (trimmed && !urls.includes(trimmed)) {\n onChange([...urls, trimmed]);\n setUrlInput(\"\");\n }\n };\n\n const remove = (idx: number) => {\n onChange(urls.filter((_, i) => i !== idx));\n };\n\n return (\n <div className=\"space-y-2\">\n <div className=\"flex flex-col gap-0.5\">\n <label className=\"text-sm font-medium\">Screenshots</label>\n <p className=\"text-xs text-text-muted\">\n Shown in the horizontal preview strip below the hero — wide landscape\n proportions work best (16:9)\n </p>\n </div>\n <div className=\"flex flex-wrap gap-3\">\n {urls.map((url, idx) => (\n <div\n key={idx}\n className=\"relative group w-36 aspect-video rounded-lg border border-border-subtle overflow-hidden bg-bg-sunken\"\n >\n <img\n src={url}\n alt={`Screenshot ${idx + 1}`}\n className=\"w-full h-full object-cover\"\n />\n <button\n type=\"button\"\n onClick={() => remove(idx)}\n className=\"absolute top-1 right-1 rounded-full bg-bg-overlay text-white p-0.5 opacity-0 group-hover:opacity-100 transition-opacity\"\n >\n <X size={12} />\n </button>\n </div>\n ))}\n {/* Upload slot */}\n <button\n type=\"button\"\n disabled={uploading}\n onClick={() => inputRef.current?.click()}\n className={cn(\n \"w-36 aspect-video rounded-lg border-2 border-dashed border-border-subtle flex flex-col items-center justify-center gap-1.5 text-text-secondary hover:border-border-strong hover:bg-bg-sunken transition-colors disabled:opacity-50\",\n )}\n >\n {uploading ? (\n <>\n <Loader2 size={18} className=\"animate-spin text-primary\" />\n <span className=\"text-xs\">{progress}%</span>\n </>\n ) : (\n <>\n <Upload size={18} />\n <span className=\"text-xs\">Add screenshot</span>\n </>\n )}\n </button>\n </div>\n {/* URL paste row */}\n <div className=\"flex gap-2\">\n <input\n type=\"url\"\n value={urlInput}\n onChange={(e) => setUrlInput(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n addUrl();\n }\n }}\n placeholder=\"https://... paste a screenshot URL\"\n className=\"flex-1 rounded-md border border-border-subtle bg-bg-input px-3 py-1.5 text-sm placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-primary/30\"\n />\n <button\n type=\"button\"\n onClick={addUrl}\n disabled={!urlInput.trim()}\n className=\"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium hover:bg-bg-sunken transition-colors disabled:opacity-50\"\n >\n Add\n </button>\n </div>\n <input\n ref={inputRef}\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp,image/gif\"\n className=\"hidden\"\n onChange={(e) => {\n const f = e.target.files?.[0];\n if (f) void handleFile(f);\n e.target.value = \"\";\n }}\n />\n {error && <p className=\"text-xs text-status-error-text\">{error}</p>}\n </div>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ImageUploadField.js","names":["_Fragment","ImageUploadField","inputRef","useRef","imgBroken","setImgBroken","useState","prevValue","value","handleFile","useCallback","upload","file","onChange","label","cn","aspectClass","Upload","uploading","Loader2","progress","f","error","hint","ScreenshotGallery","urlInput","setUrlInput","result","urls","addUrl","trimmed","remove","i","idx","StoreImage","url","StoreImagePlaceholder","X","Fragment"],"sources":["../../../src/devportal/components/ImageUploadField.tsx"],"sourcesContent":["import type { FC } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\nimport { Loader2, Upload, X } from \"lucide-react\";\nimport { StoreImage, StoreImagePlaceholder } from \"./StoreImage\";\nimport { cn } from \"../utils/cn\";\n\n// =============================================================================\n// IMAGE UPLOAD COMPONENTS\n//\n// Shared by the devportal App pages (Store Listing tab) and the physical\n// item create/edit pages — a single icon/banner upload field plus a\n// screenshot gallery, both backed by usePackageUpload's\n// upload(file) => Promise<{ downloadUrl }> shape.\n// =============================================================================\n\ninterface ImageUploadFieldProps {\n label: string;\n value: string;\n onChange: (url: string) => void;\n hint?: string;\n accept?: string;\n aspectClass?: string; // tailwind class for the preview container ratio\n upload: (file: File) => Promise<{ downloadUrl: string }>;\n uploading: boolean;\n progress: number;\n error: string | null;\n}\n\nexport const ImageUploadField: FC<ImageUploadFieldProps> = ({\n label,\n value,\n onChange,\n hint,\n accept = \"image/png,image/jpeg,image/webp,image/gif,image/svg+xml\",\n aspectClass = \"aspect-square\",\n upload,\n uploading,\n progress,\n error,\n}) => {\n const inputRef = useRef<HTMLInputElement>(null);\n const [imgBroken, setImgBroken] = useState(false);\n const prevValue = useRef(value);\n if (prevValue.current !== value) {\n prevValue.current = value;\n if (imgBroken) setImgBroken(false);\n }\n\n const handleFile = useCallback(\n async (file: File) => {\n try {\n const result = await upload(file);\n onChange(result.downloadUrl);\n } catch {\n // error shown via error prop\n }\n },\n [upload, onChange],\n );\n\n return (\n <div className=\"space-y-1.5\">\n <label className=\"text-sm font-medium\">{label}</label>\n <div className=\"flex gap-3 items-start\">\n {/* Preview */}\n <div\n className={cn(\n \"relative shrink-0 w-20 rounded-lg border border-border-subtle bg-bg-sunken overflow-hidden flex items-center justify-center\",\n aspectClass,\n )}\n >\n {value && !imgBroken ? (\n <img\n src={value}\n alt={label}\n className=\"w-full h-full object-cover\"\n onError={() => setImgBroken(true)}\n />\n ) : (\n <Upload size={18} className=\"text-text-secondary opacity-40\" />\n )}\n {uploading && (\n <div className=\"absolute inset-0 bg-bg-overlay flex flex-col items-center justify-center gap-1\">\n <Loader2 size={14} className=\"animate-spin text-white\" />\n <span className=\"text-white text-xs\">{progress}%</span>\n </div>\n )}\n </div>\n {/* URL input + upload button */}\n <div className=\"flex-1 space-y-1.5\">\n <input\n type=\"url\"\n value={value}\n onChange={(e) => onChange(e.target.value)}\n placeholder=\"https://...\"\n className=\"w-full rounded-md border border-border-subtle bg-bg-input px-3 py-1.5 text-sm placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-primary/30\"\n />\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n className=\"hidden\"\n onChange={(e) => {\n const f = e.target.files?.[0];\n if (f) void handleFile(f);\n e.target.value = \"\";\n }}\n />\n <button\n type=\"button\"\n disabled={uploading}\n onClick={() => inputRef.current?.click()}\n className=\"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium hover:bg-bg-sunken transition-colors disabled:opacity-50\"\n >\n <Upload size={11} />\n {uploading ? `Uploading… ${progress}%` : \"Upload image\"}\n </button>\n {error && <p className=\"text-xs text-status-error-text\">{error}</p>}\n </div>\n </div>\n {hint && <p className=\"text-xs text-text-secondary\">{hint}</p>}\n </div>\n );\n};\n\ninterface ScreenshotGalleryProps {\n urls: string[];\n onChange: (urls: string[]) => void;\n upload: (file: File) => Promise<{ downloadUrl: string }>;\n uploading: boolean;\n progress: number;\n error: string | null;\n}\n\nexport const ScreenshotGallery: FC<ScreenshotGalleryProps> = ({\n urls,\n onChange,\n upload,\n uploading,\n progress,\n error,\n}) => {\n const inputRef = useRef<HTMLInputElement>(null);\n const [urlInput, setUrlInput] = useState(\"\");\n\n const handleFile = useCallback(\n async (file: File) => {\n try {\n const result = await upload(file);\n onChange([...urls, result.downloadUrl]);\n } catch {\n // error shown via error prop\n }\n },\n [upload, onChange, urls],\n );\n\n const addUrl = () => {\n const trimmed = urlInput.trim();\n if (trimmed && !urls.includes(trimmed)) {\n onChange([...urls, trimmed]);\n setUrlInput(\"\");\n }\n };\n\n const remove = (idx: number) => {\n onChange(urls.filter((_, i) => i !== idx));\n };\n\n return (\n <div className=\"space-y-2\">\n <div className=\"flex flex-col gap-0.5\">\n <label className=\"text-sm font-medium\">Screenshots</label>\n <p className=\"text-xs text-text-muted\">\n Shown in the horizontal preview strip below the hero — wide landscape\n proportions work best (16:9)\n </p>\n </div>\n <div className=\"flex flex-wrap gap-3\">\n {urls.map((url, idx) => (\n <div\n key={idx}\n className=\"relative group w-36 aspect-video rounded-lg border border-border-subtle overflow-hidden bg-bg-sunken\"\n >\n <StoreImage\n src={url}\n alt={`Screenshot ${idx + 1}`}\n className=\"w-full h-full object-cover\"\n fallback={\n <StoreImagePlaceholder className=\"h-full w-full rounded-none border-0\" />\n }\n />\n <button\n type=\"button\"\n onClick={() => remove(idx)}\n className=\"absolute top-1 right-1 rounded-full bg-bg-overlay text-white p-0.5 opacity-0 group-hover:opacity-100 transition-opacity\"\n >\n <X size={12} />\n </button>\n </div>\n ))}\n {/* Upload slot */}\n <button\n type=\"button\"\n disabled={uploading}\n onClick={() => inputRef.current?.click()}\n className={cn(\n \"w-36 aspect-video rounded-lg border-2 border-dashed border-border-subtle flex flex-col items-center justify-center gap-1.5 text-text-secondary hover:border-border-strong hover:bg-bg-sunken transition-colors disabled:opacity-50\",\n )}\n >\n {uploading ? (\n <>\n <Loader2 size={18} className=\"animate-spin text-primary\" />\n <span className=\"text-xs\">{progress}%</span>\n </>\n ) : (\n <>\n <Upload size={18} />\n <span className=\"text-xs\">Add screenshot</span>\n </>\n )}\n </button>\n </div>\n {/* URL paste row */}\n <div className=\"flex gap-2\">\n <input\n type=\"url\"\n value={urlInput}\n onChange={(e) => setUrlInput(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n addUrl();\n }\n }}\n placeholder=\"https://... paste a screenshot URL\"\n className=\"flex-1 rounded-md border border-border-subtle bg-bg-input px-3 py-1.5 text-sm placeholder:text-text-placeholder focus:outline-none focus:ring-2 focus:ring-primary/30\"\n />\n <button\n type=\"button\"\n onClick={addUrl}\n disabled={!urlInput.trim()}\n className=\"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium hover:bg-bg-sunken transition-colors disabled:opacity-50\"\n >\n Add\n </button>\n </div>\n <input\n ref={inputRef}\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp,image/gif\"\n className=\"hidden\"\n onChange={(e) => {\n const f = e.target.files?.[0];\n if (f) void handleFile(f);\n e.target.value = \"\";\n }}\n />\n {error && <p className=\"text-xs text-status-error-text\">{error}</p>}\n </div>\n );\n};\n"],"mappings":"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"}
|
|
@@ -6,7 +6,7 @@ interface SectionDef {
|
|
|
6
6
|
fallback: string;
|
|
7
7
|
icon: typeof LayoutDashboard;
|
|
8
8
|
/** Additional sub-paths that should mark this section as active. */
|
|
9
|
-
matchPrefixes?: string[];
|
|
9
|
+
matchPrefixes?: readonly string[];
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* Org context switcher — can be embedded standalone or inside SectionNav.
|
|
@@ -4,84 +4,108 @@ import "../hooks/useGraphQL.js";
|
|
|
4
4
|
import { cn as r } from "../utils/cn.js";
|
|
5
5
|
import { useGetMyOrganizationsQuery as i } from "../../generated/global-operations.js";
|
|
6
6
|
import { useTr as a } from "../utils/i18n.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { getSectionEntries as o, getSectionMatchPrefixes as s } from "../utils/developerNav.js";
|
|
8
|
+
import { useEffect as c, useRef as l, useState as u } from "react";
|
|
9
|
+
import { AppWindow as d, BarChart3 as f, Blocks as p, BookOpen as m, Building2 as h, Check as g, ChevronDown as _, DollarSign as v, LayoutDashboard as y, Library as b, Plus as x, Settings as S, Share2 as C, User as w } from "lucide-react";
|
|
9
10
|
import "react-router";
|
|
10
|
-
import { jsx as
|
|
11
|
+
import { jsx as T, jsxs as E } from "react/jsx-runtime";
|
|
11
12
|
// src/devportal/components/SectionNav.tsx
|
|
12
|
-
var
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
var D = {
|
|
14
|
+
dashboard: y,
|
|
15
|
+
apps: d,
|
|
16
|
+
"timecampus-applets": p,
|
|
17
|
+
organizations: h,
|
|
18
|
+
delegations: C,
|
|
19
|
+
revenue: v,
|
|
20
|
+
docs: m,
|
|
21
|
+
resources: b,
|
|
22
|
+
publisher: w,
|
|
23
|
+
settings: S,
|
|
24
|
+
analytics: f
|
|
25
|
+
};
|
|
26
|
+
function O(e) {
|
|
27
|
+
return o(e).map((t) => ({
|
|
28
|
+
path: t.id === "publisher" ? "publisher/profile" : t.path,
|
|
29
|
+
i18nKey: t.i18nKey,
|
|
30
|
+
fallback: t.fallback,
|
|
31
|
+
icon: D[t.id] ?? y,
|
|
32
|
+
matchPrefixes: s(e, t.id)
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
O(!1), O(!0);
|
|
36
|
+
var k = () => {
|
|
37
|
+
let { activeOrgId: o, setActiveOrgId: s, activePhysicalOrgId: d, setActivePhysicalOrgId: f, navigate: p, basePath: m } = e(), v = n(), y = v ? d : o, b = v ? f : s, S = a(), [C, w] = u(!1), D = l(null), { data: O } = i({ fetchPolicy: "cache-first" }), k = (O?.myDeveloperOrganizations ?? []).filter((e) => v ? e.kind === "PHYSICAL" : e.kind !== "PHYSICAL");
|
|
38
|
+
c(() => {
|
|
39
|
+
!y && k.length === 1 && k[0]?.id && b(k[0].id);
|
|
16
40
|
}, [
|
|
17
41
|
k,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
]),
|
|
42
|
+
y,
|
|
43
|
+
b
|
|
44
|
+
]), c(() => {
|
|
21
45
|
let e = (e) => {
|
|
22
|
-
D.current && !D.current.contains(e.target) &&
|
|
46
|
+
D.current && !D.current.contains(e.target) && w(!1);
|
|
23
47
|
};
|
|
24
|
-
return
|
|
25
|
-
}, [
|
|
26
|
-
let A = k.find((e) => e.id ===
|
|
27
|
-
return /* @__PURE__ */
|
|
48
|
+
return C && document.addEventListener("mousedown", e), () => document.removeEventListener("mousedown", e);
|
|
49
|
+
}, [C]);
|
|
50
|
+
let A = k.find((e) => e.id === y)?.name ?? (k.length === 0 ? S("devportal.nav.noOrg", "No organization") : S("devportal.nav.selectOrg", "Select org")), j = t(v, "new");
|
|
51
|
+
return /* @__PURE__ */ E("div", {
|
|
28
52
|
ref: D,
|
|
29
53
|
className: "relative flex-shrink-0",
|
|
30
|
-
children: [/* @__PURE__ */
|
|
54
|
+
children: [/* @__PURE__ */ E("button", {
|
|
31
55
|
type: "button",
|
|
32
|
-
onClick: () =>
|
|
56
|
+
onClick: () => w((e) => !e),
|
|
33
57
|
className: r("inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors", "bg-bg-surface text-text-primary border-border-subtle hover:bg-accent"),
|
|
34
|
-
"aria-label":
|
|
58
|
+
"aria-label": S("devportal.nav.switchOrg", "Switch organization"),
|
|
35
59
|
children: [
|
|
36
|
-
/* @__PURE__ */
|
|
60
|
+
/* @__PURE__ */ T(h, {
|
|
37
61
|
size: 13,
|
|
38
62
|
className: "text-primary flex-shrink-0"
|
|
39
63
|
}),
|
|
40
|
-
/* @__PURE__ */
|
|
64
|
+
/* @__PURE__ */ T("span", {
|
|
41
65
|
className: "max-w-[140px] truncate",
|
|
42
66
|
children: A
|
|
43
67
|
}),
|
|
44
|
-
/* @__PURE__ */
|
|
68
|
+
/* @__PURE__ */ T(_, {
|
|
45
69
|
size: 13,
|
|
46
|
-
className: r("transition-transform",
|
|
70
|
+
className: r("transition-transform", C && "rotate-180")
|
|
47
71
|
})
|
|
48
72
|
]
|
|
49
|
-
}),
|
|
73
|
+
}), C && /* @__PURE__ */ E("div", {
|
|
50
74
|
className: "absolute right-0 top-full z-50 mt-1 min-w-[200px] rounded-md border border-border-subtle bg-popover shadow-lg py-1",
|
|
51
75
|
children: [
|
|
52
|
-
k.map((e) => /* @__PURE__ */
|
|
76
|
+
k.map((e) => /* @__PURE__ */ E("button", {
|
|
53
77
|
type: "button",
|
|
54
78
|
onClick: () => {
|
|
55
|
-
|
|
79
|
+
b(e.id), w(!1);
|
|
56
80
|
},
|
|
57
|
-
className: r("flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors hover:bg-accent", e.id ===
|
|
58
|
-
children: [/* @__PURE__ */
|
|
81
|
+
className: r("flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors hover:bg-accent", e.id === y ? "text-primary font-medium" : "text-text-primary"),
|
|
82
|
+
children: [/* @__PURE__ */ T(g, {
|
|
59
83
|
size: 13,
|
|
60
|
-
className: r("flex-shrink-0", e.id ===
|
|
61
|
-
}), /* @__PURE__ */
|
|
84
|
+
className: r("flex-shrink-0", e.id === y ? "opacity-100 text-primary" : "opacity-0")
|
|
85
|
+
}), /* @__PURE__ */ T("span", {
|
|
62
86
|
className: "truncate",
|
|
63
87
|
children: e.name
|
|
64
88
|
})]
|
|
65
89
|
}, e.id)),
|
|
66
|
-
k.length > 0 && /* @__PURE__ */
|
|
67
|
-
/* @__PURE__ */
|
|
90
|
+
k.length > 0 && /* @__PURE__ */ T("div", { className: "my-1 border-t border-border-subtle" }),
|
|
91
|
+
/* @__PURE__ */ E("button", {
|
|
68
92
|
type: "button",
|
|
69
93
|
onClick: () => {
|
|
70
|
-
|
|
94
|
+
w(!1), p?.(`${m ?? ""}${j}`);
|
|
71
95
|
},
|
|
72
96
|
className: "flex w-full items-center gap-2 px-3 py-2 text-sm text-left text-primary transition-colors hover:bg-accent",
|
|
73
|
-
children: [/* @__PURE__ */
|
|
97
|
+
children: [/* @__PURE__ */ T(x, {
|
|
74
98
|
size: 13,
|
|
75
99
|
className: "flex-shrink-0"
|
|
76
|
-
}), /* @__PURE__ */
|
|
100
|
+
}), /* @__PURE__ */ T("span", {
|
|
77
101
|
className: "truncate",
|
|
78
|
-
children:
|
|
102
|
+
children: S("devportal.nav.newOrganization", "New organization")
|
|
79
103
|
})]
|
|
80
104
|
})
|
|
81
105
|
]
|
|
82
106
|
})]
|
|
83
107
|
});
|
|
84
108
|
};
|
|
85
|
-
export {
|
|
109
|
+
export { k as OrgSwitcher };
|
|
86
110
|
|
|
87
111
|
//# sourceMappingURL=SectionNav.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SectionNav.js","names":["OrgSwitcher","useDevportal","isSeller","useIsSellerSection","currentOrgId","activePhysicalOrgId","activeOrgId","setCurrentOrgId","setActivePhysicalOrgId","setActiveOrgId","tr","useTr","open","setOpen","useState","ref","useRef","useGetMyOrganizationsQuery","orgs","data","org","useEffect","handler","label","o","createOrgPath","orgSectionPath","v","cn","Building2","ChevronDown","Check","navigate","basePath","Plus"],"sources":["../../../src/devportal/components/SectionNav.tsx"],"sourcesContent":["import type { FC } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { useLocation } from \"react-router\";\nimport {\n LayoutDashboard,\n AppWindow,\n Blocks,\n Building2,\n Share2,\n DollarSign,\n BookOpen,\n Library,\n User,\n Settings,\n ChevronDown,\n Check,\n Plus,\n} from \"lucide-react\";\nimport { useDevportal } from \"../providers/DevportalProvider\";\nimport {\n useIsSellerSection,\n orgSectionPath,\n} from \"../hooks/useIsSellerSection\";\nimport {\n useGetMyOrganizationsQuery,\n type GetMyOrganizationsQuery,\n} from \"../hooks/useGraphQL\";\nimport { cn } from \"../utils/cn\";\nimport { useTr } from \"../utils/i18n\";\n\ntype OrgItem = GetMyOrganizationsQuery[\"myDeveloperOrganizations\"][number];\n\ninterface SectionDef {\n path: string;\n i18nKey: string;\n fallback: string;\n icon: typeof LayoutDashboard;\n /** Additional sub-paths that should mark this section as active. */\n matchPrefixes?: string[];\n}\n\nconst SECTIONS: ReadonlyArray<SectionDef> = [\n {\n path: \"\",\n i18nKey: \"devportal.nav.dashboard\",\n fallback: \"Dashboard\",\n icon: LayoutDashboard,\n },\n {\n path: \"apps\",\n i18nKey: \"devportal.nav.apps\",\n fallback: \"Apps\",\n icon: AppWindow,\n },\n {\n path: \"timecampus-applets\",\n i18nKey: \"devportal.nav.timecampusApplets\",\n fallback: \"TimeCampus Applets\",\n icon: Blocks,\n },\n {\n path: \"organizations\",\n i18nKey: \"devportal.nav.organizations\",\n fallback: \"Organizations\",\n icon: Building2,\n },\n {\n path: \"delegations\",\n i18nKey: \"devportal.nav.delegations\",\n fallback: \"Delegations\",\n icon: Share2,\n },\n {\n path: \"revenue\",\n i18nKey: \"devportal.nav.revenue\",\n fallback: \"Revenue\",\n icon: DollarSign,\n },\n {\n path: \"docs\",\n i18nKey: \"devportal.nav.docs\",\n fallback: \"Docs\",\n icon: BookOpen,\n },\n {\n path: \"resources\",\n i18nKey: \"devportal.nav.resources\",\n fallback: \"Resources\",\n icon: Library,\n },\n {\n path: \"publisher/profile\",\n i18nKey: \"devportal.nav.publisher\",\n fallback: \"Publisher\",\n icon: User,\n matchPrefixes: [\"publisher\"],\n },\n {\n path: \"settings\",\n i18nKey: \"devportal.nav.settings\",\n fallback: \"Settings\",\n icon: Settings,\n },\n];\n\nfunction joinBaseAndPath(basePath: string | undefined, path: string): string {\n const base = (basePath ?? \"\").replace(/\\/$/, \"\");\n if (!path) return base || \"/\";\n const suffix = path.startsWith(\"/\") ? path : `/${path}`;\n return `${base}${suffix}`;\n}\n\nfunction isSectionActive(\n pathname: string,\n basePath: string | undefined,\n section: SectionDef,\n): boolean {\n const sectionUrl = joinBaseAndPath(basePath, section.path);\n // Index/dashboard is active only when exactly matched (or with empty extra).\n if (section.path === \"\") {\n return pathname === sectionUrl || pathname === `${sectionUrl}/`;\n }\n const candidates = [section.path, ...(section.matchPrefixes ?? [])];\n return candidates.some((prefix) => {\n const url = joinBaseAndPath(basePath, prefix);\n return pathname === url || pathname.startsWith(`${url}/`);\n });\n}\n\n/**\n * Org context switcher — can be embedded standalone or inside SectionNav.\n *\n * Shared between the regular devportal and the seller portal: filters the\n * org list to PHYSICAL-kind orgs under /devportal/seller/** and DIGITAL-kind\n * everywhere else, and reads/writes the matching active-org slot\n * (activePhysicalOrgId vs activeOrgId) so this switcher and\n * useSellerOrganization (used by the item-creation flow) always agree on\n * which org is current. See DeveloperOrgKind (BOFF-7285).\n */\nexport const OrgSwitcher: FC = () => {\n const {\n activeOrgId,\n setActiveOrgId,\n activePhysicalOrgId,\n setActivePhysicalOrgId,\n navigate,\n basePath,\n } = useDevportal();\n const isSeller = useIsSellerSection();\n const currentOrgId = isSeller ? activePhysicalOrgId : activeOrgId;\n const setCurrentOrgId = isSeller ? setActivePhysicalOrgId : setActiveOrgId;\n const tr = useTr();\n const [open, setOpen] = useState(false);\n const ref = useRef<HTMLDivElement>(null);\n\n const { data } = useGetMyOrganizationsQuery({ fetchPolicy: \"cache-first\" });\n const allOrgs: OrgItem[] = data?.myDeveloperOrganizations ?? [];\n const orgs = allOrgs.filter((org) =>\n isSeller ? org.kind === \"PHYSICAL\" : org.kind !== \"PHYSICAL\",\n );\n\n // Auto-select when there's exactly one matching org and nothing is stored yet\n useEffect(() => {\n if (!currentOrgId && orgs.length === 1 && orgs[0]?.id) {\n setCurrentOrgId(orgs[0].id);\n }\n }, [orgs, currentOrgId, setCurrentOrgId]);\n\n // Close on outside click\n useEffect(() => {\n const handler = (e: MouseEvent) => {\n if (ref.current && !ref.current.contains(e.target as Node)) {\n setOpen(false);\n }\n };\n if (open) document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [open]);\n\n const activeOrg = orgs.find((o) => o.id === currentOrgId);\n const label =\n activeOrg?.name ??\n (orgs.length === 0\n ? tr(\"devportal.nav.noOrg\", \"No organization\")\n : tr(\"devportal.nav.selectOrg\", \"Select org\"));\n\n const createOrgPath = orgSectionPath(isSeller, \"new\");\n\n return (\n <div ref={ref} className=\"relative flex-shrink-0\">\n <button\n type=\"button\"\n onClick={() => setOpen((v) => !v)}\n className={cn(\n \"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors\",\n \"bg-bg-surface text-text-primary border-border-subtle hover:bg-accent\",\n )}\n aria-label={tr(\"devportal.nav.switchOrg\", \"Switch organization\")}\n >\n <Building2 size={13} className=\"text-primary flex-shrink-0\" />\n <span className=\"max-w-[140px] truncate\">{label}</span>\n <ChevronDown\n size={13}\n className={cn(\"transition-transform\", open && \"rotate-180\")}\n />\n </button>\n\n {open && (\n <div className=\"absolute right-0 top-full z-50 mt-1 min-w-[200px] rounded-md border border-border-subtle bg-popover shadow-lg py-1\">\n {orgs.map((org) => (\n <button\n key={org.id}\n type=\"button\"\n onClick={() => {\n setCurrentOrgId(org.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors hover:bg-accent\",\n org.id === currentOrgId\n ? \"text-primary font-medium\"\n : \"text-text-primary\",\n )}\n >\n <Check\n size={13}\n className={cn(\n \"flex-shrink-0\",\n org.id === currentOrgId\n ? \"opacity-100 text-primary\"\n : \"opacity-0\",\n )}\n />\n <span className=\"truncate\">{org.name}</span>\n </button>\n ))}\n {orgs.length > 0 && (\n <div className=\"my-1 border-t border-border-subtle\" />\n )}\n <button\n type=\"button\"\n onClick={() => {\n setOpen(false);\n navigate?.(`${basePath ?? \"\"}${createOrgPath}`);\n }}\n className=\"flex w-full items-center gap-2 px-3 py-2 text-sm text-left text-primary transition-colors hover:bg-accent\"\n >\n <Plus size={13} className=\"flex-shrink-0\" />\n <span className=\"truncate\">\n {tr(\"devportal.nav.newOrganization\", \"New organization\")}\n </span>\n </button>\n </div>\n )}\n </div>\n );\n};\n\n/**\n * Horizontal section nav rendered above the page content on every devportal\n * route. Gives developers a one-click jump between all top-level sections so\n * routes like OAuth, Delegations, Analytics, Revenue, Docs, Resources,\n * Publisher Profile, and Settings are reachable without typing URLs.\n *\n * Renders nothing when `basePath` is undefined (standalone/dev mode) so it\n * doesn't conflict with whatever harness wraps the MFE there.\n */\nexport const SectionNav: FC = () => {\n const { navigate, basePath } = useDevportal();\n const { pathname } = useLocation();\n const tr = useTr();\n\n if (!basePath || !navigate) return null;\n\n const handleClick = (path: string) => {\n navigate(joinBaseAndPath(basePath, path));\n };\n\n return (\n <nav\n aria-label={tr(\"devportal.nav.sections\", \"Developer portal sections\")}\n className=\"border-b border-border-subtle bg-bg-surface/40 px-4 py-2\"\n data-testid=\"devportal-section-nav\"\n >\n <div className=\"flex items-center gap-2\">\n <ul className=\"flex flex-1 flex-wrap gap-1\">\n {SECTIONS.map((section) => {\n const active = isSectionActive(pathname, basePath, section);\n const Icon = section.icon;\n return (\n <li key={section.path}>\n <button\n type=\"button\"\n onClick={() => handleClick(section.path)}\n aria-current={active ? \"page\" : undefined}\n className={cn(\n \"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors\",\n active\n ? \"bg-action-primary-bg text-action-primary-text\"\n : \"text-text-secondary hover:bg-accent hover:text-text-primary\",\n )}\n >\n <Icon size={14} />\n <span>{tr(section.i18nKey, section.fallback)}</span>\n </button>\n </li>\n );\n })}\n </ul>\n <OrgSwitcher />\n </div>\n </nav>\n );\n};\n\n// Exported for unit tests that want to assert against the canonical list.\nexport const __SECTION_NAV_SECTIONS = SECTIONS;\n"],"mappings":";;;;;;;;;;;AA2IA,IAAaA,UAAwB;CACnC,IAAM,EACJ,gBACA,mBACA,wBACA,2BACA,aACA,gBACEC,EAAa,GACXC,IAAWC,EAAmB,GAC9BC,IAAeF,IAAWG,IAAsBC,GAChDC,IAAkBL,IAAWM,IAAyBC,GACtDC,IAAKC,EAAM,GACX,CAACC,GAAMC,KAAWC,EAAS,EAAK,GAChCC,IAAMC,EAAuB,IAAI,GAEjC,EAAE,YAASC,EAA2B,EAAE,aAAa,cAAc,CAAC,GAEpEC,KADqBC,GAAM,4BAA4B,CAAC,EAAA,CACzC,QAAQ,MAC3BjB,IAAWkB,EAAI,SAAS,aAAaA,EAAI,SAAS,UACpD;CAUA,AAPAC,QAAgB;EACd,AAAI,CAACjB,KAAgBc,EAAK,WAAW,KAAKA,EAAK,EAAE,EAAE,MACjDX,EAAgBW,EAAK,EAAE,CAAC,EAAE;CAE9B,GAAG;EAACA;EAAMd;EAAcG;CAAe,CAAC,GAGxCc,QAAgB;EACd,IAAMC,KAAW,MAAkB;GACjC,AAAIP,EAAI,WAAW,CAACA,EAAI,QAAQ,SAAS,EAAE,MAAc,KACvDF,EAAQ,EAAK;EAEjB;EAEA,OADID,KAAM,SAAS,iBAAiB,aAAaU,CAAO,SAC3C,SAAS,oBAAoB,aAAaA,CAAO;CAChE,GAAG,CAACV,CAAI,CAAC;CAGT,IAAMW,IADYL,EAAK,MAAM,MAAMM,EAAE,OAAOpB,CAE1C,CAAA,EAAW,SACVc,EAAK,WAAW,IACbR,EAAG,uBAAuB,iBAAiB,IAC3CA,EAAG,2BAA2B,YAAY,IAE1Ce,IAAgBC,EAAexB,GAAU,KAAK;CAEpD,OACE,kBAAC,OAAD;EAAU;EAAK,WAAU;EAAzB,UAAA,CACE,kBAAC,UAAD;GACE,MAAK;GACL,eAAeW,GAAS,MAAM,CAACc,CAAC;GAChC,WAAWC,EACT,wGACA,sEACF;GACA,cAAYlB,EAAG,2BAA2B,qBAAqB;GAPjE,UAAA;IASE,kBAACmB,GAAD;KAAW,MAAM;KAAI,WAAU;IAA8B,CAAA;IAC7D,kBAAC,QAAD;KAAM,WAAU;KAA0B,UAAAN;IAAY,CAAA;IACtD,kBAACO,GAAD;KACE,MAAM;KACN,WAAWF,EAAG,wBAAwBhB,KAAQ,YAAY;IAC3D,CAAA;GACK;EAEP,CAAA,GAAAA,KACC,kBAAC,OAAD;GAAK,WAAU;GAAf,UAAA;IACGM,EAAK,KAAK,MACT,kBAAC,UAAD;KAEE,MAAK;KACL,eAAe;MAEb,AADAX,EAAgBa,EAAI,EAAE,GACtBP,EAAQ,EAAK;KACf;KACA,WAAWe,EACT,gGACAR,EAAI,OAAOhB,IACP,6BACA,mBACN;KAZF,UAAA,CAcE,kBAAC2B,GAAD;MACE,MAAM;MACN,WAAWH,EACT,iBACAR,EAAI,OAAOhB,IACP,6BACA,WACN;KACD,CAAA,GACD,kBAAC,QAAD;MAAM,WAAU;MAAY,UAAAgB,EAAI;KAAW,CAAA,CACrC;IAvBD,GAAAA,EAAI,EAuBH,CACT;IACAF,EAAK,SAAS,KACb,kBAAC,OAAD,EAAK,WAAU,qCAAsC,CAAA;IAEvD,kBAAC,UAAD;KACE,MAAK;KACL,eAAe;MAEb,AADAL,EAAQ,EAAK,GACbmB,IAAW,GAAGC,KAAY,KAAKR,GAAe;KAChD;KACA,WAAU;KANZ,UAAA,CAQE,kBAACS,GAAD;MAAM,MAAM;MAAI,WAAU;KAAiB,CAAA,GAC3C,kBAAC,QAAD;MAAM,WAAU;MACb,UAAAxB,EAAG,iCAAiC,kBAAkB;KACnD,CAAA,CACA;;GACL;EAEJ,CAAA,CAAA;;AAET"}
|
|
1
|
+
{"version":3,"file":"SectionNav.js","names":["SECTION_ICONS","LayoutDashboard","AppWindow","Blocks","Building2","Share2","DollarSign","BookOpen","Library","User","Settings","BarChart3","buildSections","getSectionEntries","isFluidgrids","entry","getSectionMatchPrefixes","OrgSwitcher","useDevportal","isSeller","useIsSellerSection","currentOrgId","activePhysicalOrgId","activeOrgId","setCurrentOrgId","setActivePhysicalOrgId","setActiveOrgId","tr","useTr","open","setOpen","useState","ref","useRef","useGetMyOrganizationsQuery","orgs","data","org","useEffect","handler","label","o","createOrgPath","orgSectionPath","v","cn","ChevronDown","Check","navigate","basePath","Plus"],"sources":["../../../src/devportal/components/SectionNav.tsx"],"sourcesContent":["import type { FC } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { useLocation } from \"react-router\";\nimport {\n LayoutDashboard,\n AppWindow,\n Blocks,\n Building2,\n Share2,\n DollarSign,\n BookOpen,\n Library,\n User,\n Settings,\n ChevronDown,\n Check,\n Plus,\n BarChart3,\n} from \"lucide-react\";\nimport {\n useDevportal,\n useIsFluidgridsVariant,\n} from \"../providers/DevportalProvider\";\nimport {\n useIsSellerSection,\n orgSectionPath,\n} from \"../hooks/useIsSellerSection\";\nimport {\n useGetMyOrganizationsQuery,\n type GetMyOrganizationsQuery,\n} from \"../hooks/useGraphQL\";\nimport { cn } from \"../utils/cn\";\nimport { useTr } from \"../utils/i18n\";\nimport {\n getSectionEntries,\n getSectionMatchPrefixes,\n} from \"../utils/developerNav\";\n\ntype OrgItem = GetMyOrganizationsQuery[\"myDeveloperOrganizations\"][number];\n\ninterface SectionDef {\n path: string;\n i18nKey: string;\n fallback: string;\n icon: typeof LayoutDashboard;\n /** Additional sub-paths that should mark this section as active. */\n matchPrefixes?: readonly string[];\n}\n\nconst SECTION_ICONS: Record<string, typeof LayoutDashboard> = {\n dashboard: LayoutDashboard,\n apps: AppWindow,\n \"timecampus-applets\": Blocks,\n organizations: Building2,\n delegations: Share2,\n revenue: DollarSign,\n docs: BookOpen,\n resources: Library,\n publisher: User,\n settings: Settings,\n analytics: BarChart3,\n};\n\n/** Section list for a variant, built from the shared nav data. */\nfunction buildSections(isFluidgrids: boolean): ReadonlyArray<SectionDef> {\n return getSectionEntries(isFluidgrids).map((entry) => ({\n path: entry.id === \"publisher\" ? \"publisher/profile\" : entry.path,\n i18nKey: entry.i18nKey,\n fallback: entry.fallback,\n icon: SECTION_ICONS[entry.id] ?? LayoutDashboard,\n matchPrefixes: getSectionMatchPrefixes(isFluidgrids, entry.id),\n }));\n}\n\nconst SECTIONS: ReadonlyArray<SectionDef> = buildSections(false);\nconst FLUIDGRIDS_SECTIONS: ReadonlyArray<SectionDef> = buildSections(true);\n\nfunction joinBaseAndPath(basePath: string | undefined, path: string): string {\n const base = (basePath ?? \"\").replace(/\\/$/, \"\");\n if (!path) return base || \"/\";\n const suffix = path.startsWith(\"/\") ? path : `/${path}`;\n return `${base}${suffix}`;\n}\n\nfunction isSectionActive(\n pathname: string,\n basePath: string | undefined,\n section: SectionDef,\n): boolean {\n const sectionUrl = joinBaseAndPath(basePath, section.path);\n // Index/dashboard is active only when exactly matched (or with empty extra).\n if (section.path === \"\") {\n return pathname === sectionUrl || pathname === `${sectionUrl}/`;\n }\n const candidates = [section.path, ...(section.matchPrefixes ?? [])];\n return candidates.some((prefix) => {\n const url = joinBaseAndPath(basePath, prefix);\n return pathname === url || pathname.startsWith(`${url}/`);\n });\n}\n\n/**\n * Org context switcher — can be embedded standalone or inside SectionNav.\n *\n * Shared between the regular devportal and the seller portal: filters the\n * org list to PHYSICAL-kind orgs under /devportal/seller/** and DIGITAL-kind\n * everywhere else, and reads/writes the matching active-org slot\n * (activePhysicalOrgId vs activeOrgId) so this switcher and\n * useSellerOrganization (used by the item-creation flow) always agree on\n * which org is current. See DeveloperOrgKind (BOFF-7285).\n */\nexport const OrgSwitcher: FC = () => {\n const {\n activeOrgId,\n setActiveOrgId,\n activePhysicalOrgId,\n setActivePhysicalOrgId,\n navigate,\n basePath,\n } = useDevportal();\n const isSeller = useIsSellerSection();\n const currentOrgId = isSeller ? activePhysicalOrgId : activeOrgId;\n const setCurrentOrgId = isSeller ? setActivePhysicalOrgId : setActiveOrgId;\n const tr = useTr();\n const [open, setOpen] = useState(false);\n const ref = useRef<HTMLDivElement>(null);\n\n const { data } = useGetMyOrganizationsQuery({ fetchPolicy: \"cache-first\" });\n const allOrgs: OrgItem[] = data?.myDeveloperOrganizations ?? [];\n const orgs = allOrgs.filter((org) =>\n isSeller ? org.kind === \"PHYSICAL\" : org.kind !== \"PHYSICAL\",\n );\n\n // Auto-select when there's exactly one matching org and nothing is stored yet\n useEffect(() => {\n if (!currentOrgId && orgs.length === 1 && orgs[0]?.id) {\n setCurrentOrgId(orgs[0].id);\n }\n }, [orgs, currentOrgId, setCurrentOrgId]);\n\n // Close on outside click\n useEffect(() => {\n const handler = (e: MouseEvent) => {\n if (ref.current && !ref.current.contains(e.target as Node)) {\n setOpen(false);\n }\n };\n if (open) document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [open]);\n\n const activeOrg = orgs.find((o) => o.id === currentOrgId);\n const label =\n activeOrg?.name ??\n (orgs.length === 0\n ? tr(\"devportal.nav.noOrg\", \"No organization\")\n : tr(\"devportal.nav.selectOrg\", \"Select org\"));\n\n const createOrgPath = orgSectionPath(isSeller, \"new\");\n\n return (\n <div ref={ref} className=\"relative flex-shrink-0\">\n <button\n type=\"button\"\n onClick={() => setOpen((v) => !v)}\n className={cn(\n \"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors\",\n \"bg-bg-surface text-text-primary border-border-subtle hover:bg-accent\",\n )}\n aria-label={tr(\"devportal.nav.switchOrg\", \"Switch organization\")}\n >\n <Building2 size={13} className=\"text-primary flex-shrink-0\" />\n <span className=\"max-w-[140px] truncate\">{label}</span>\n <ChevronDown\n size={13}\n className={cn(\"transition-transform\", open && \"rotate-180\")}\n />\n </button>\n\n {open && (\n <div className=\"absolute right-0 top-full z-50 mt-1 min-w-[200px] rounded-md border border-border-subtle bg-popover shadow-lg py-1\">\n {orgs.map((org) => (\n <button\n key={org.id}\n type=\"button\"\n onClick={() => {\n setCurrentOrgId(org.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors hover:bg-accent\",\n org.id === currentOrgId\n ? \"text-primary font-medium\"\n : \"text-text-primary\",\n )}\n >\n <Check\n size={13}\n className={cn(\n \"flex-shrink-0\",\n org.id === currentOrgId\n ? \"opacity-100 text-primary\"\n : \"opacity-0\",\n )}\n />\n <span className=\"truncate\">{org.name}</span>\n </button>\n ))}\n {orgs.length > 0 && (\n <div className=\"my-1 border-t border-border-subtle\" />\n )}\n <button\n type=\"button\"\n onClick={() => {\n setOpen(false);\n navigate?.(`${basePath ?? \"\"}${createOrgPath}`);\n }}\n className=\"flex w-full items-center gap-2 px-3 py-2 text-sm text-left text-primary transition-colors hover:bg-accent\"\n >\n <Plus size={13} className=\"flex-shrink-0\" />\n <span className=\"truncate\">\n {tr(\"devportal.nav.newOrganization\", \"New organization\")}\n </span>\n </button>\n </div>\n )}\n </div>\n );\n};\n\n/**\n * Horizontal section nav rendered above the page content on every devportal\n * route. Gives developers a one-click jump between all top-level sections so\n * routes like OAuth, Delegations, Analytics, Revenue, Docs, Resources,\n * Publisher Profile, and Settings are reachable without typing URLs.\n *\n * Renders nothing when `basePath` is undefined (standalone/dev mode) so it\n * doesn't conflict with whatever harness wraps the MFE there.\n */\nexport const SectionNav: FC = () => {\n const { navigate, basePath } = useDevportal();\n const { pathname } = useLocation();\n const tr = useTr();\n const isFluidgrids = useIsFluidgridsVariant();\n const sections = isFluidgrids ? FLUIDGRIDS_SECTIONS : SECTIONS;\n\n if (!basePath || !navigate) return null;\n\n const handleClick = (path: string) => {\n navigate(joinBaseAndPath(basePath, path));\n };\n\n return (\n <nav\n aria-label={tr(\"devportal.nav.sections\", \"Developer portal sections\")}\n className=\"border-b border-border-subtle bg-bg-surface/40 px-4 py-2\"\n data-testid=\"devportal-section-nav\"\n >\n <div className=\"flex items-center gap-2\">\n <ul className=\"flex flex-1 flex-wrap gap-1\">\n {sections.map((section) => {\n const active = isSectionActive(pathname, basePath, section);\n const Icon = section.icon;\n return (\n <li key={section.path}>\n <button\n type=\"button\"\n onClick={() => handleClick(section.path)}\n aria-current={active ? \"page\" : undefined}\n className={cn(\n \"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors\",\n active\n ? \"bg-action-primary-bg text-action-primary-text\"\n : \"text-text-secondary hover:bg-accent hover:text-text-primary\",\n )}\n >\n <Icon size={14} />\n <span>{tr(section.i18nKey, section.fallback)}</span>\n </button>\n </li>\n );\n })}\n </ul>\n <OrgSwitcher />\n </div>\n </nav>\n );\n};\n\n// Exported for unit tests that want to assert against the canonical list.\nexport const __SECTION_NAV_SECTIONS = SECTIONS;\n"],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FC, ImgHTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
/** True when `src` is a value the browser can load as an image. */
|
|
3
|
+
export declare function isRenderableImageSrc(src: string | null | undefined): src is string;
|
|
4
|
+
export interface StoreImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "onError"> {
|
|
5
|
+
src: string | null | undefined;
|
|
6
|
+
alt: string;
|
|
7
|
+
/** Shown instead of the image when it is missing or fails to load. */
|
|
8
|
+
fallback?: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
export declare const StoreImage: FC<StoreImageProps>;
|
|
11
|
+
/** Neutral tile for a store image that can't be shown. Size it via className. */
|
|
12
|
+
export declare const StoreImagePlaceholder: FC<{
|
|
13
|
+
className?: string;
|
|
14
|
+
iconSize?: number;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cn as e } from "../utils/cn.js";
|
|
2
|
+
import { useState as t } from "react";
|
|
3
|
+
import { ImageOff as n } from "lucide-react";
|
|
4
|
+
import { Fragment as r, jsx as i } from "react/jsx-runtime";
|
|
5
|
+
// src/devportal/components/StoreImage.tsx
|
|
6
|
+
var a = /^(?:https?:\/\/|data:image\/|blob:|\/(?!\/))/i;
|
|
7
|
+
function o(e) {
|
|
8
|
+
return typeof e == "string" && a.test(e.trim());
|
|
9
|
+
}
|
|
10
|
+
var s = ({ src: e, alt: n, fallback: a = null, ...s }) => {
|
|
11
|
+
let [c, l] = t(null);
|
|
12
|
+
return !o(e) || c === e ? /* @__PURE__ */ i(r, { children: a }) : /* @__PURE__ */ i("img", {
|
|
13
|
+
...s,
|
|
14
|
+
src: e,
|
|
15
|
+
alt: n,
|
|
16
|
+
onError: () => l(e)
|
|
17
|
+
});
|
|
18
|
+
}, c = ({ className: t, iconSize: r = 18 }) => /* @__PURE__ */ i("div", {
|
|
19
|
+
role: "img",
|
|
20
|
+
"aria-label": "Image unavailable",
|
|
21
|
+
className: e("flex shrink-0 items-center justify-center rounded-input border border-border-subtle bg-bg-sunken", t),
|
|
22
|
+
children: /* @__PURE__ */ i(n, {
|
|
23
|
+
size: r,
|
|
24
|
+
className: "text-text-muted",
|
|
25
|
+
"aria-hidden": "true"
|
|
26
|
+
})
|
|
27
|
+
});
|
|
28
|
+
export { s as StoreImage, c as StoreImagePlaceholder, o as isRenderableImageSrc };
|
|
29
|
+
|
|
30
|
+
//# sourceMappingURL=StoreImage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"StoreImage.js","names":["_Fragment","RENDERABLE_SRC","isRenderableImageSrc","src","StoreImage","imgProps","failedSrc","setFailedSrc","useState","Fragment","fallback","StoreImagePlaceholder","cn","className","ImageOff","iconSize"],"sources":["../../../src/devportal/components/StoreImage.tsx"],"sourcesContent":["import type { FC, ImgHTMLAttributes, ReactNode } from \"react\";\nimport { useState } from \"react\";\nimport { ImageOff } from \"lucide-react\";\nimport { cn } from \"../utils/cn\";\n\n// =============================================================================\n// STORE IMAGE\n//\n// Renders a store listing image (app/item icon, banner, screenshot) and swaps\n// in a fallback when the value can't be shown: empty, not a browser-loadable\n// URL (e.g. a bare Iconify name like \"mdi:robot\"), or the request fails (an\n// expired presigned link, a 404, a rate-limited third-party host).\n// fe-libs has no image-with-fallback primitive, so this lives here.\n// =============================================================================\n\nconst RENDERABLE_SRC = /^(?:https?:\\/\\/|data:image\\/|blob:|\\/(?!\\/))/i;\n\n/** True when `src` is a value the browser can load as an image. */\nexport function isRenderableImageSrc(\n src: string | null | undefined,\n): src is string {\n return typeof src === \"string\" && RENDERABLE_SRC.test(src.trim());\n}\n\nexport interface StoreImageProps extends Omit<\n ImgHTMLAttributes<HTMLImageElement>,\n \"src\" | \"onError\"\n> {\n src: string | null | undefined;\n alt: string;\n /** Shown instead of the image when it is missing or fails to load. */\n fallback?: ReactNode;\n}\n\nexport const StoreImage: FC<StoreImageProps> = ({\n src,\n alt,\n fallback = null,\n ...imgProps\n}) => {\n // Remember which src failed rather than a boolean, so a new src (e.g.\n // after a re-upload) gets a fresh attempt without an effect.\n const [failedSrc, setFailedSrc] = useState<string | null>(null);\n\n if (!isRenderableImageSrc(src) || failedSrc === src) {\n return <>{fallback}</>;\n }\n\n return (\n <img {...imgProps} src={src} alt={alt} onError={() => setFailedSrc(src)} />\n );\n};\n\n/** Neutral tile for a store image that can't be shown. Size it via className. */\nexport const StoreImagePlaceholder: FC<{\n className?: string;\n iconSize?: number;\n}> = ({ className, iconSize = 18 }) => (\n <div\n role=\"img\"\n aria-label=\"Image unavailable\"\n className={cn(\n \"flex shrink-0 items-center justify-center rounded-input border border-border-subtle bg-bg-sunken\",\n className,\n )}\n >\n <ImageOff size={iconSize} className=\"text-text-muted\" aria-hidden=\"true\" />\n </div>\n);\n"],"mappings":";;;;;AAeA,IAAMC,IAAiB;AAGvB,SAAgBC,EACd,GACe;CACf,OAAO,OAAOC,KAAQ,YAAYF,EAAe,KAAKE,EAAI,KAAK,CAAC;AAClE;AAYA,IAAaC,KAAmC,EAC9C,QACA,QACA,cAAW,MACX,GAAGC,QACC;CAGJ,IAAM,CAACC,GAAWC,KAAgBC,EAAwB,IAAI;CAM9D,OAJI,CAACN,EAAqBC,CAAG,KAAKG,MAAcH,IACvC,kBAAAH,GAAA,EAAA,UAAGU,EAAW,CAAA,IAIrB,kBAAC,OAAD;EAAK,GAAIL;EAAe;EAAU;EAAK,eAAeE,EAAaJ,CAAG;CAAI,CAAA;AAE9E,GAGaQ,KAGP,EAAE,cAAW,cAAW,SAC5B,kBAAC,OAAD;CACE,MAAK;CACL,cAAW;CACX,WAAWC,EACT,oGACAC,CACF;CAEA,UAAA,kBAACC,GAAD;EAAU,MAAMC;EAAU,WAAU;EAAkB,eAAY;CAAQ,CAAA;AACvE,CAAA"}
|