@pipe0/react 1.0.38 → 1.0.40

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.
@@ -0,0 +1,273 @@
1
+ import { cn } from "../lib/utils.mjs";
2
+ import { Building2, Check, ExternalLink, X } from "../components/internal/icons.mjs";
3
+ import { createContext, useContext, useId, useState } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+
6
+ //#region src/records/record-widgets.tsx
7
+ /**
8
+ * Rewrites every image URL the records components render (avatars, logos,
9
+ * favicons). Identity by default; a host with a strict image policy (the MCP
10
+ * Apps view) routes them through one allowed domain.
11
+ */
12
+ const RecordImageUrlContext = createContext((src) => src);
13
+ function faviconUrl(domain) {
14
+ return `https://www.google.com/s2/favicons?sz=32&domain=${domain.replace(/^(https?:\/\/)?(www\.)?/, "")}`;
15
+ }
16
+ function hideBrokenImage(e) {
17
+ const target = e.currentTarget;
18
+ target.style.display = "none";
19
+ target.onerror = null;
20
+ }
21
+ const AVATAR_COLORS = [
22
+ "pz:bg-red-100 pz:text-red-800",
23
+ "pz:bg-blue-100 pz:text-blue-800",
24
+ "pz:bg-green-100 pz:text-green-800",
25
+ "pz:bg-yellow-100 pz:text-yellow-800",
26
+ "pz:bg-purple-100 pz:text-purple-800",
27
+ "pz:bg-pink-100 pz:text-pink-800",
28
+ "pz:bg-indigo-100 pz:text-indigo-800",
29
+ "pz:bg-cyan-100 pz:text-cyan-800",
30
+ "pz:bg-orange-100 pz:text-orange-800",
31
+ "pz:bg-teal-100 pz:text-teal-800",
32
+ "pz:bg-amber-100 pz:text-amber-800",
33
+ "pz:bg-lime-100 pz:text-lime-800"
34
+ ];
35
+ function avatarColor(name) {
36
+ let hash = 0;
37
+ for (let i = 0; i < name.length; i++) {
38
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
39
+ hash = hash & hash;
40
+ }
41
+ return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
42
+ }
43
+ function avatarLetters(name) {
44
+ const parts = name.trim().split(/\s+/).filter(Boolean);
45
+ if (parts.length === 0) return "AN";
46
+ return ((Array.from(parts[0])[0] ?? "") + (parts.length > 1 ? Array.from(parts[parts.length - 1])[0] ?? "" : "")).toUpperCase() || "AN";
47
+ }
48
+ /** 24px round avatar: the image when it loads, coloured initials otherwise. */
49
+ function RecordAvatar({ name, imageSrc }) {
50
+ const imageUrl = useContext(RecordImageUrlContext);
51
+ const [broken, setBroken] = useState(false);
52
+ const showImage = Boolean(imageSrc) && !broken;
53
+ return /* @__PURE__ */ jsx("span", {
54
+ className: cn("pz:relative pz:flex pz:size-6 pz:shrink-0 pz:overflow-hidden pz:rounded-full pz:select-none", "pz:shadow-[inset_0_0_0_0.5px_rgba(17,17,17,0.1)]", !showImage && avatarColor(name)),
55
+ children: showImage ? /* @__PURE__ */ jsx("img", {
56
+ src: imageSrc ? imageUrl(imageSrc) : void 0,
57
+ loading: "lazy",
58
+ decoding: "async",
59
+ alt: name,
60
+ className: "pz:aspect-square pz:size-full pz:object-cover",
61
+ onError: () => setBroken(true)
62
+ }) : /* @__PURE__ */ jsx("span", {
63
+ className: "pz:flex pz:size-full pz:items-center pz:justify-center pz:text-[10px] pz:font-medium pz:tracking-tight",
64
+ children: avatarLetters(name)
65
+ })
66
+ });
67
+ }
68
+ /**
69
+ * LinkedIn's mark, drawn as the real "in" bug: one filled rounded square with
70
+ * the letters cut out (an SVG mask), so the letters show whatever surface the
71
+ * badge sits on.
72
+ */
73
+ function LinkedinMark({ className }) {
74
+ const maskId = useId();
75
+ return /* @__PURE__ */ jsxs("svg", {
76
+ viewBox: "0 0 24 24",
77
+ "aria-hidden": "true",
78
+ className,
79
+ children: [/* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsxs("mask", {
80
+ id: maskId,
81
+ children: [
82
+ /* @__PURE__ */ jsx("rect", {
83
+ x: "1.5",
84
+ y: "1.5",
85
+ width: "21",
86
+ height: "21",
87
+ rx: "4.6",
88
+ fill: "white"
89
+ }),
90
+ /* @__PURE__ */ jsx("circle", {
91
+ cx: "7.15",
92
+ cy: "7.15",
93
+ r: "1.65",
94
+ fill: "black"
95
+ }),
96
+ /* @__PURE__ */ jsx("rect", {
97
+ x: "5.65",
98
+ y: "9.75",
99
+ width: "3",
100
+ height: "8.75",
101
+ rx: "0.35",
102
+ fill: "black"
103
+ }),
104
+ /* @__PURE__ */ jsx("path", {
105
+ fill: "black",
106
+ d: "M10.55 9.75h2.85v1.3c.5-.85 1.5-1.55 2.95-1.55 2.3 0 3.25 1.5 3.25 3.85v5.15h-2.95v-4.6c0-1.15-.4-1.9-1.45-1.9-1.15 0-1.8.85-1.8 2v4.5h-2.85z"
107
+ })
108
+ ]
109
+ }) }), /* @__PURE__ */ jsx("rect", {
110
+ x: "1.5",
111
+ y: "1.5",
112
+ width: "21",
113
+ height: "21",
114
+ rx: "4.6",
115
+ fill: "currentColor",
116
+ mask: `url(#${maskId})`
117
+ })]
118
+ });
119
+ }
120
+ /** LinkedIn's mark in LinkedIn blue, in a 20px slot. */
121
+ function LinkedinBadge({ className, iconClassName }) {
122
+ return /* @__PURE__ */ jsx("span", {
123
+ className: cn("pz:flex pz:size-5 pz:shrink-0 pz:items-center pz:justify-center pz:text-[#0A66C2] pz:dark:text-[#3d8ee0]", className),
124
+ children: /* @__PURE__ */ jsx(LinkedinMark, { className: cn("pz:size-4", iconClassName) })
125
+ });
126
+ }
127
+ const SENIORITY_BARS = {
128
+ seniority_junior: 0,
129
+ seniority_frontline: 1,
130
+ seniority_ic: 1,
131
+ seniority_senior_ic: 2,
132
+ seniority_manager: 2,
133
+ seniority_leadership: 3,
134
+ seniority_c_suite: 4
135
+ };
136
+ /**
137
+ * Seniority is a rank, so it renders as one: four bars filled up to the
138
+ * level (the priority glyph convention).
139
+ */
140
+ function SeniorityBars({ level }) {
141
+ return /* @__PURE__ */ jsx("svg", {
142
+ role: "img",
143
+ "aria-label": `Seniority ${level} of 4`,
144
+ viewBox: "0 0 14 14",
145
+ className: "pz:size-3 pz:shrink-0 pz:text-muted-foreground",
146
+ fill: "currentColor",
147
+ children: [
148
+ 5,
149
+ 8,
150
+ 11,
151
+ 14
152
+ ].map((h, i) => /* @__PURE__ */ jsx("rect", {
153
+ x: i * 3.6,
154
+ y: 14 - h,
155
+ width: 2.6,
156
+ height: h,
157
+ rx: .8,
158
+ opacity: i < level ? 1 : .25
159
+ }, h))
160
+ });
161
+ }
162
+ function IconWidget({ widget }) {
163
+ if (widget.key === "linkedin") return /* @__PURE__ */ jsx(LinkedinBadge, {});
164
+ if (widget.key === "job") return /* @__PURE__ */ jsx(Building2, {
165
+ strokeWidth: .8,
166
+ className: "pz:size-4"
167
+ });
168
+ const level = SENIORITY_BARS[widget.key];
169
+ if (level === void 0) return null;
170
+ return /* @__PURE__ */ jsx(SeniorityBars, { level });
171
+ }
172
+ /**
173
+ * The one glyph for a yes/no state in a cell: a quiet check tile, an empty
174
+ * tile, or the failed tile. Booleans and action outcomes share it so a
175
+ * column of "Enrolled" reads like a column of "True" — one vocabulary, gray.
176
+ */
177
+ function StateTile({ state }) {
178
+ if (state === "failed") return /* @__PURE__ */ jsx("span", {
179
+ "aria-hidden": true,
180
+ className: "pz:flex pz:size-4 pz:shrink-0 pz:items-center pz:justify-center pz:rounded-[4px] pz:bg-failed pz:text-failed-foreground",
181
+ children: /* @__PURE__ */ jsx(X, {
182
+ className: "pz:size-3",
183
+ strokeWidth: 2.5
184
+ })
185
+ });
186
+ if (state === "checked") return /* @__PURE__ */ jsx("span", {
187
+ "aria-hidden": true,
188
+ className: "pz:flex pz:size-4 pz:shrink-0 pz:items-center pz:justify-center pz:rounded-[4px] pz:border pz:border-input pz:bg-muted pz:text-muted-foreground",
189
+ children: /* @__PURE__ */ jsx(Check, {
190
+ className: "pz:size-3",
191
+ strokeWidth: 2.5
192
+ })
193
+ });
194
+ return /* @__PURE__ */ jsx("span", {
195
+ "aria-hidden": true,
196
+ className: "pz:size-4 pz:shrink-0 pz:rounded-[4px] pz:border pz:border-input"
197
+ });
198
+ }
199
+ function ActionSummaryWidget({ widget }) {
200
+ if (widget.result_status === "completed") return /* @__PURE__ */ jsx(StateTile, { state: "checked" });
201
+ if (widget.result_status === "failed") return /* @__PURE__ */ jsx(StateTile, { state: "failed" });
202
+ widget.result_status;
203
+ return null;
204
+ }
205
+ /** Favicons and logos render raw at 16px — no tile chrome, no cast shadow. */
206
+ function LogoImage({ src, alt }) {
207
+ return /* @__PURE__ */ jsx("img", {
208
+ src: useContext(RecordImageUrlContext)(src),
209
+ alt,
210
+ loading: "lazy",
211
+ decoding: "async",
212
+ className: "pz:inline-block pz:size-4 pz:shrink-0 pz:rounded-[3.5px] pz:object-contain",
213
+ onError: hideBrokenImage
214
+ });
215
+ }
216
+ /**
217
+ * Icon-only anchor in the left widget rail. The value text stays plain (cell
218
+ * clicks keep their normal behavior); only the icon navigates.
219
+ */
220
+ function ExternalLinkWidget({ url }) {
221
+ return /* @__PURE__ */ jsx("a", {
222
+ href: url,
223
+ target: "_blank",
224
+ rel: "noreferrer noopener",
225
+ onClick: (e) => e.stopPropagation(),
226
+ title: url,
227
+ className: "pz:shrink-0 pz:text-muted-foreground/60 pz:hover:text-foreground",
228
+ children: /* @__PURE__ */ jsx(ExternalLink, { className: "pz:size-3.5" })
229
+ });
230
+ }
231
+ /** A favicon for link formats that carry no logo widget of their own. */
232
+ function FormatGlyph({ field }) {
233
+ if (field.status !== "completed" || typeof field.value !== "string") return null;
234
+ if (field.format === "website_url" || field.format === "url") return /* @__PURE__ */ jsx(LogoImage, {
235
+ src: faviconUrl(field.value),
236
+ alt: "Favicon"
237
+ });
238
+ if (field.format === "domain" && !field.widgets?.entity_logo) return /* @__PURE__ */ jsx(LogoImage, {
239
+ src: faviconUrl(field.value),
240
+ alt: "Favicon"
241
+ });
242
+ return null;
243
+ }
244
+ /** The glyph widgets a field carries, in the order given. */
245
+ function RecordWidgets({ field, widgets, className }) {
246
+ const available = field.widgets;
247
+ if (!available) return null;
248
+ const rendered = widgets.flatMap((key) => {
249
+ if (key === "avatar" && available.avatar) return [/* @__PURE__ */ jsx(RecordAvatar, {
250
+ name: available.avatar.name,
251
+ imageSrc: available.avatar.image_url
252
+ }, key)];
253
+ if (key === "entity_logo" && available.entity_logo?.image_url) return [/* @__PURE__ */ jsx(LogoImage, {
254
+ src: available.entity_logo.image_url,
255
+ alt: available.entity_logo.entity
256
+ }, key)];
257
+ if (key === "external_link" && available.external_link?.url && field.status === "completed") return [/* @__PURE__ */ jsx(ExternalLinkWidget, { url: available.external_link.url }, key)];
258
+ if (key === "location_indicator" && available.location_indicator) return [/* @__PURE__ */ jsx("span", { children: available.location_indicator.emoji }, key)];
259
+ if (key === "emoji" && available.emoji) return [/* @__PURE__ */ jsx("span", { children: available.emoji.value }, key)];
260
+ if (key === "icon" && available.icon) return [/* @__PURE__ */ jsx(IconWidget, { widget: available.icon }, key)];
261
+ if (key === "action_summary" && available.action_summary) return [/* @__PURE__ */ jsx(ActionSummaryWidget, { widget: available.action_summary }, key)];
262
+ return [];
263
+ });
264
+ if (rendered.length === 0) return null;
265
+ return /* @__PURE__ */ jsx("span", {
266
+ className: cn("pz:flex pz:shrink-0 pz:items-center pz:gap-1", className),
267
+ children: rendered
268
+ });
269
+ }
270
+
271
+ //#endregion
272
+ export { FormatGlyph, LinkedinBadge, LinkedinMark, RecordAvatar, RecordImageUrlContext, RecordWidgets, StateTile };
273
+ //# sourceMappingURL=record-widgets.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"record-widgets.mjs","names":[],"sources":["../../src/records/record-widgets.tsx"],"sourcesContent":["import type { RecordField, Widgets } from \"@pipe0/base\";\nimport { createContext, type SyntheticEvent, useContext, useId, useState } from \"react\";\nimport { Building2, Check, ExternalLink, X } from \"../components/internal/icons.js\";\nimport { cn } from \"../lib/utils.js\";\n\n/** The slice of a record field the read-only renderers look at. */\nexport type RecordValueField = Pick<\n RecordField,\n \"value\" | \"status\" | \"type\" | \"format\" | \"reason\" | \"widgets\"\n>;\n\n/**\n * Rewrites every image URL the records components render (avatars, logos,\n * favicons). Identity by default; a host with a strict image policy (the MCP\n * Apps view) routes them through one allowed domain.\n */\nexport const RecordImageUrlContext = createContext<(src: string) => string>((src) => src);\n\n// Same URL as @pipe0/base's getFaviconUrlGoogle, kept local so this module\n// stays free of @pipe0/base value imports (see utils/field-icons.tsx).\nfunction faviconUrl(domain: string): string {\n return `https://www.google.com/s2/favicons?sz=32&domain=${domain.replace(/^(https?:\\/\\/)?(www\\.)?/, \"\")}`;\n}\n\nfunction hideBrokenImage(e: SyntheticEvent<HTMLImageElement>) {\n const target = e.currentTarget;\n target.style.display = \"none\";\n target.onerror = null;\n}\n\nconst AVATAR_COLORS = [\n \"pz:bg-red-100 pz:text-red-800\",\n \"pz:bg-blue-100 pz:text-blue-800\",\n \"pz:bg-green-100 pz:text-green-800\",\n \"pz:bg-yellow-100 pz:text-yellow-800\",\n \"pz:bg-purple-100 pz:text-purple-800\",\n \"pz:bg-pink-100 pz:text-pink-800\",\n \"pz:bg-indigo-100 pz:text-indigo-800\",\n \"pz:bg-cyan-100 pz:text-cyan-800\",\n \"pz:bg-orange-100 pz:text-orange-800\",\n \"pz:bg-teal-100 pz:text-teal-800\",\n \"pz:bg-amber-100 pz:text-amber-800\",\n \"pz:bg-lime-100 pz:text-lime-800\",\n];\n\n// Same hash as the dash avatars, so a person keeps their colour everywhere.\nfunction avatarColor(name: string): string {\n let hash = 0;\n for (let i = 0; i < name.length; i++) {\n hash = name.charCodeAt(i) + ((hash << 5) - hash);\n hash = hash & hash;\n }\n return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];\n}\n\nfunction avatarLetters(name: string): string {\n const parts = name.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return \"AN\";\n const first = Array.from(parts[0])[0] ?? \"\";\n const last = parts.length > 1 ? (Array.from(parts[parts.length - 1])[0] ?? \"\") : \"\";\n return (first + last).toUpperCase() || \"AN\";\n}\n\n/** 24px round avatar: the image when it loads, coloured initials otherwise. */\nexport function RecordAvatar({ name, imageSrc }: { name: string; imageSrc?: string | null }) {\n const imageUrl = useContext(RecordImageUrlContext);\n const [broken, setBroken] = useState(false);\n const showImage = Boolean(imageSrc) && !broken;\n return (\n <span\n className={cn(\n \"pz:relative pz:flex pz:size-6 pz:shrink-0 pz:overflow-hidden pz:rounded-full pz:select-none\",\n \"pz:shadow-[inset_0_0_0_0.5px_rgba(17,17,17,0.1)]\",\n !showImage && avatarColor(name),\n )}\n >\n {showImage ? (\n <img\n src={imageSrc ? imageUrl(imageSrc) : undefined}\n loading=\"lazy\"\n decoding=\"async\"\n alt={name}\n className=\"pz:aspect-square pz:size-full pz:object-cover\"\n onError={() => setBroken(true)}\n />\n ) : (\n <span className=\"pz:flex pz:size-full pz:items-center pz:justify-center pz:text-[10px] pz:font-medium pz:tracking-tight\">\n {avatarLetters(name)}\n </span>\n )}\n </span>\n );\n}\n\n/**\n * LinkedIn's mark, drawn as the real \"in\" bug: one filled rounded square with\n * the letters cut out (an SVG mask), so the letters show whatever surface the\n * badge sits on.\n */\nexport function LinkedinMark({ className }: { className?: string }) {\n const maskId = useId();\n return (\n <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" className={className}>\n <defs>\n <mask id={maskId}>\n <rect x=\"1.5\" y=\"1.5\" width=\"21\" height=\"21\" rx=\"4.6\" fill=\"white\" />\n {/* i */}\n <circle cx=\"7.15\" cy=\"7.15\" r=\"1.65\" fill=\"black\" />\n <rect x=\"5.65\" y=\"9.75\" width=\"3\" height=\"8.75\" rx=\"0.35\" fill=\"black\" />\n {/* n */}\n <path\n fill=\"black\"\n d=\"M10.55 9.75h2.85v1.3c.5-.85 1.5-1.55 2.95-1.55 2.3 0 3.25 1.5 3.25 3.85v5.15h-2.95v-4.6c0-1.15-.4-1.9-1.45-1.9-1.15 0-1.8.85-1.8 2v4.5h-2.85z\"\n />\n </mask>\n </defs>\n <rect\n x=\"1.5\"\n y=\"1.5\"\n width=\"21\"\n height=\"21\"\n rx=\"4.6\"\n fill=\"currentColor\"\n mask={`url(#${maskId})`}\n />\n </svg>\n );\n}\n\n/** LinkedIn's mark in LinkedIn blue, in a 20px slot. */\nexport function LinkedinBadge({\n className,\n iconClassName,\n}: {\n className?: string;\n iconClassName?: string;\n}) {\n return (\n <span\n className={cn(\n \"pz:flex pz:size-5 pz:shrink-0 pz:items-center pz:justify-center pz:text-[#0A66C2] pz:dark:text-[#3d8ee0]\",\n className,\n )}\n >\n <LinkedinMark className={cn(\"pz:size-4\", iconClassName)} />\n </span>\n );\n}\n\n// Filled bars per seniority bucket. Unknown renders nothing rather than a\n// placeholder glyph — absence is quieter than a question mark.\nconst SENIORITY_BARS: Partial<Record<NonNullable<Widgets[\"icon\"]>[\"key\"], number>> = {\n seniority_junior: 0,\n seniority_frontline: 1,\n seniority_ic: 1,\n seniority_senior_ic: 2,\n seniority_manager: 2,\n seniority_leadership: 3,\n seniority_c_suite: 4,\n};\n\n/**\n * Seniority is a rank, so it renders as one: four bars filled up to the\n * level (the priority glyph convention).\n */\nfunction SeniorityBars({ level }: { level: number }) {\n const heights = [5, 8, 11, 14];\n return (\n <svg\n role=\"img\"\n aria-label={`Seniority ${level} of 4`}\n viewBox=\"0 0 14 14\"\n className=\"pz:size-3 pz:shrink-0 pz:text-muted-foreground\"\n fill=\"currentColor\"\n >\n {heights.map((h, i) => (\n <rect\n key={h}\n x={i * 3.6}\n y={14 - h}\n width={2.6}\n height={h}\n rx={0.8}\n opacity={i < level ? 1 : 0.25}\n />\n ))}\n </svg>\n );\n}\n\nfunction IconWidget({ widget }: { widget: NonNullable<Widgets[\"icon\"]> }) {\n if (widget.key === \"linkedin\") return <LinkedinBadge />;\n if (widget.key === \"job\") return <Building2 strokeWidth={0.8} className=\"pz:size-4\" />;\n const level = SENIORITY_BARS[widget.key];\n if (level === undefined) return null;\n return <SeniorityBars level={level} />;\n}\n\n/**\n * The one glyph for a yes/no state in a cell: a quiet check tile, an empty\n * tile, or the failed tile. Booleans and action outcomes share it so a\n * column of \"Enrolled\" reads like a column of \"True\" — one vocabulary, gray.\n */\nexport function StateTile({ state }: { state: \"checked\" | \"empty\" | \"failed\" }) {\n if (state === \"failed\") {\n return (\n <span\n aria-hidden\n className=\"pz:flex pz:size-4 pz:shrink-0 pz:items-center pz:justify-center pz:rounded-[4px] pz:bg-failed pz:text-failed-foreground\"\n >\n <X className=\"pz:size-3\" strokeWidth={2.5} />\n </span>\n );\n }\n if (state === \"checked\") {\n return (\n <span\n aria-hidden\n className=\"pz:flex pz:size-4 pz:shrink-0 pz:items-center pz:justify-center pz:rounded-[4px] pz:border pz:border-input pz:bg-muted pz:text-muted-foreground\"\n >\n <Check className=\"pz:size-3\" strokeWidth={2.5} />\n </span>\n );\n }\n return (\n <span\n aria-hidden\n className=\"pz:size-4 pz:shrink-0 pz:rounded-[4px] pz:border pz:border-input\"\n />\n );\n}\n\nfunction ActionSummaryWidget({ widget }: { widget: NonNullable<Widgets[\"action_summary\"]> }) {\n if (widget.result_status === \"completed\") return <StateTile state=\"checked\" />;\n if (widget.result_status === \"failed\") return <StateTile state=\"failed\" />;\n const _: never = widget.result_status;\n return null;\n}\n\n/** Favicons and logos render raw at 16px — no tile chrome, no cast shadow. */\nfunction LogoImage({ src, alt }: { src: string; alt: string }) {\n const imageUrl = useContext(RecordImageUrlContext);\n return (\n <img\n src={imageUrl(src)}\n alt={alt}\n loading=\"lazy\"\n decoding=\"async\"\n className=\"pz:inline-block pz:size-4 pz:shrink-0 pz:rounded-[3.5px] pz:object-contain\"\n onError={hideBrokenImage}\n />\n );\n}\n\n/**\n * Icon-only anchor in the left widget rail. The value text stays plain (cell\n * clicks keep their normal behavior); only the icon navigates.\n */\nfunction ExternalLinkWidget({ url }: { url: string }) {\n return (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n onClick={(e) => e.stopPropagation()}\n title={url}\n className=\"pz:shrink-0 pz:text-muted-foreground/60 pz:hover:text-foreground\"\n >\n <ExternalLink className=\"pz:size-3.5\" />\n </a>\n );\n}\n\n/** A favicon for link formats that carry no logo widget of their own. */\nexport function FormatGlyph({ field }: { field: RecordValueField }) {\n if (field.status !== \"completed\" || typeof field.value !== \"string\") return null;\n\n if (field.format === \"website_url\" || field.format === \"url\") {\n return <LogoImage src={faviconUrl(field.value)} alt=\"Favicon\" />;\n }\n // Catalog domain fields (company_domain) already carry an entity_logo\n // widget; only fill in for user-made domain columns so the tile is not doubled.\n if (field.format === \"domain\" && !field.widgets?.entity_logo) {\n return <LogoImage src={faviconUrl(field.value)} alt=\"Favicon\" />;\n }\n return null;\n}\n\nexport type RecordWidgetKey =\n | \"avatar\"\n | \"entity_logo\"\n | \"external_link\"\n | \"location_indicator\"\n | \"icon\"\n | \"emoji\"\n | \"action_summary\";\n\n/** The glyph widgets a field carries, in the order given. */\nexport function RecordWidgets({\n field,\n widgets,\n className,\n}: {\n field: RecordValueField;\n widgets: readonly RecordWidgetKey[];\n className?: string;\n}) {\n const available = field.widgets;\n if (!available) return null;\n\n const rendered = widgets.flatMap((key) => {\n if (key === \"avatar\" && available.avatar) {\n return [\n <RecordAvatar\n key={key}\n name={available.avatar.name}\n imageSrc={available.avatar.image_url}\n />,\n ];\n }\n if (key === \"entity_logo\" && available.entity_logo?.image_url) {\n return [\n <LogoImage\n key={key}\n src={available.entity_logo.image_url}\n alt={available.entity_logo.entity}\n />,\n ];\n }\n if (key === \"external_link\" && available.external_link?.url && field.status === \"completed\") {\n return [<ExternalLinkWidget key={key} url={available.external_link.url} />];\n }\n if (key === \"location_indicator\" && available.location_indicator) {\n return [<span key={key}>{available.location_indicator.emoji}</span>];\n }\n if (key === \"emoji\" && available.emoji) {\n return [<span key={key}>{available.emoji.value}</span>];\n }\n if (key === \"icon\" && available.icon) {\n return [<IconWidget key={key} widget={available.icon} />];\n }\n if (key === \"action_summary\" && available.action_summary) {\n return [<ActionSummaryWidget key={key} widget={available.action_summary} />];\n }\n return [];\n });\n\n if (rendered.length === 0) return null;\n\n // Row layout matters once a field carries more than one widget (e.g.\n // x_profile_match emits entity_logo + avatar).\n return (\n <span className={cn(\"pz:flex pz:shrink-0 pz:items-center pz:gap-1\", className)}>\n {rendered}\n </span>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ import * as _$react_jsx_runtime0 from "react/jsx-runtime";
2
+ import { RecordField } from "@pipe0/base";
3
+
4
+ //#region src/records/records-table.d.ts
5
+ /**
6
+ * A cell as both run responses carry it: a pipes record's field
7
+ * (`RecordField`) or a search result's field, which leaves `type` and
8
+ * `format` to its column's definition.
9
+ */
10
+ type RecordsTableCell = Pick<RecordField, "value" | "status" | "widgets"> & Partial<Pick<RecordField, "type" | "format" | "reason">>;
11
+ /**
12
+ * One row of native cells, field → cell: a pipes record's `fields`, or one
13
+ * search result, exactly as the API returns it.
14
+ */
15
+ type RecordsTableRow = Record<string, RecordsTableCell | null | undefined>;
16
+ /**
17
+ * The column metadata the table reads. Pipes field definitions
18
+ * (`PipesResponse.field_definitions`) and search field annotations
19
+ * (`SearchResponse.field_definitions`) both fit as they are.
20
+ */
21
+ interface RecordsTableColumn {
22
+ label?: string | null;
23
+ type?: RecordField["type"];
24
+ format?: RecordField["format"];
25
+ order?: number | null;
26
+ /** Input columns get no outcome count; pipe and search outputs do. */
27
+ added_by?: {
28
+ ref: string;
29
+ };
30
+ }
31
+ interface RecordsTableProps {
32
+ rows: RecordsTableRow[];
33
+ fields?: Record<string, RecordsTableColumn>;
34
+ className?: string;
35
+ /** Rewrites image URLs (avatars, logos, favicons), e.g. through an image proxy. */
36
+ imageUrl?: (src: string) => string;
37
+ }
38
+ /**
39
+ * Read-only table of a run's native rows: one column per field, each cell
40
+ * rendered the way a pipe0 Sheet renders it, with its status. Empty cells
41
+ * show a muted dash; failed cells keep their reason.
42
+ */
43
+ declare function RecordsTable({
44
+ rows,
45
+ fields,
46
+ imageUrl,
47
+ className
48
+ }: RecordsTableProps): _$react_jsx_runtime0.JSX.Element;
49
+ //#endregion
50
+ export { RecordsTable, RecordsTableCell, RecordsTableColumn, RecordsTableProps, RecordsTableRow };
51
+ //# sourceMappingURL=records-table.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"records-table.d.mts","names":[],"sources":["../../src/records/records-table.tsx"],"mappings":";;;;;;;AAcA;;KAAY,gBAAA,GAAmB,IAAA,CAAK,WAAA,oCAClC,OAAA,CAAQ,IAAA,CAAK,WAAA;;;;;KAMH,eAAA,GAAkB,MAAA,SAAe,gBAAA;;;;;;UAO5B,kBAAA;EACf,KAAA;EACA,IAAA,GAAO,WAAA;EACP,MAAA,GAAS,WAAA;EACT,KAAA;EAXyB;EAazB,QAAA;IAAa,GAAA;EAAA;AAAA;AAAA,UAGE,iBAAA;EACf,IAAA,EAAM,eAAA;EACN,MAAA,GAAS,MAAA,SAAe,kBAAA;EACxB,SAAA;EAVA;EAYA,QAAA,IAAY,GAAA;AAAA;;;;;;iBAuHE,YAAA,CAAA;EAAe,IAAA;EAAM,MAAA;EAAa,QAAA;EAAU;AAAA,GAAa,iBAAA,GAAiB,oBAAA,CAAA,GAAA,CAAA,OAAA"}
@@ -0,0 +1,168 @@
1
+ import { cn } from "../lib/utils.mjs";
2
+ import { Check, Copy } from "../components/internal/icons.mjs";
3
+ import { getFieldFormatIcon } from "../utils/field-icons.mjs";
4
+ import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table.mjs";
5
+ import { RecordImageUrlContext, StateTile } from "./record-widgets.mjs";
6
+ import { RecordValue } from "./record-value.mjs";
7
+ import { useState } from "react";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+
10
+ //#region src/records/records-table.tsx
11
+ /** Declared columns by their display order, then any field only the rows carry. `id` is never shown. */
12
+ function columnNames(fields, rows) {
13
+ const declared = Object.entries(fields).map(([name, column], index) => ({
14
+ name,
15
+ rank: column.order ?? Number.MAX_SAFE_INTEGER,
16
+ index
17
+ })).sort((a, b) => a.rank - b.rank || a.index - b.index).map(({ name }) => name);
18
+ const names = new Set(declared);
19
+ for (const row of rows) for (const name of Object.keys(row)) names.add(name);
20
+ names.delete("id");
21
+ return [...names];
22
+ }
23
+ function tallyColumn(rows, name) {
24
+ const tally = {
25
+ completed: 0,
26
+ no_result: 0,
27
+ failed: 0,
28
+ total: 0
29
+ };
30
+ for (const row of rows) {
31
+ const status = row[name]?.status;
32
+ if (!status) continue;
33
+ tally.total += 1;
34
+ if (status === "completed") tally.completed += 1;
35
+ else if (status === "no_result") tally.no_result += 1;
36
+ else if (status === "failed") tally.failed += 1;
37
+ }
38
+ return tally;
39
+ }
40
+ function isEmpty(cell) {
41
+ return !cell || cell.status === "completed" && (cell.value == null || cell.value === "");
42
+ }
43
+ /** What a cell's copy button puts on the clipboard; null = no button (empty or trivial values). */
44
+ function copyableText(value) {
45
+ if (value === null || value === "" || typeof value === "boolean") return null;
46
+ if (typeof value === "object") return JSON.stringify(value, null, 2);
47
+ return String(value);
48
+ }
49
+ async function writeClipboard(text) {
50
+ try {
51
+ await navigator.clipboard.writeText(text);
52
+ return true;
53
+ } catch {
54
+ const area = document.createElement("textarea");
55
+ area.value = text;
56
+ area.setAttribute("readonly", "");
57
+ area.style.position = "fixed";
58
+ area.style.opacity = "0";
59
+ document.body.appendChild(area);
60
+ area.select();
61
+ const copied = document.execCommand("copy");
62
+ area.remove();
63
+ return copied;
64
+ }
65
+ }
66
+ function CopyCellButton({ text }) {
67
+ const [copied, setCopied] = useState(false);
68
+ return /* @__PURE__ */ jsx("button", {
69
+ type: "button",
70
+ "aria-label": "Copy value",
71
+ title: copied ? "Copied" : "Copy",
72
+ onClick: async (e) => {
73
+ e.stopPropagation();
74
+ if (!await writeClipboard(text)) return;
75
+ setCopied(true);
76
+ setTimeout(() => setCopied(false), 1500);
77
+ },
78
+ className: cn("pz:absolute pz:top-1/2 pz:right-1.5 pz:-translate-y-1/2 pz:inline-flex pz:size-6 pz:items-center pz:justify-center pz:rounded-md", "pz:border pz:border-border pz:bg-background pz:text-muted-foreground pz:shadow-xs pz:hover:text-foreground", "pz:opacity-0 pz:transition-opacity pz:group-hover/cell:opacity-100 pz:focus-visible:opacity-100", copied && "pz:opacity-100"),
79
+ children: copied ? /* @__PURE__ */ jsx(Check, { className: "pz:size-3.5" }) : /* @__PURE__ */ jsx(Copy, { className: "pz:size-3.5" })
80
+ });
81
+ }
82
+ function TallyBadge({ tally }) {
83
+ return /* @__PURE__ */ jsxs("span", {
84
+ title: [
85
+ `${tally.completed} completed`,
86
+ tally.no_result ? `${tally.no_result} no result` : null,
87
+ tally.failed ? `${tally.failed} failed` : null
88
+ ].filter(Boolean).join(" · "),
89
+ className: cn("pz:ml-1.5 pz:rounded pz:px-1 pz:font-normal pz:text-[11px] pz:tabular-nums", tally.failed > 0 ? "pz:bg-failed pz:text-failed-foreground" : "pz:text-muted-foreground"),
90
+ children: [
91
+ tally.completed,
92
+ "/",
93
+ tally.total
94
+ ]
95
+ });
96
+ }
97
+ /**
98
+ * Read-only table of a run's native rows: one column per field, each cell
99
+ * rendered the way a pipe0 Sheet renders it, with its status. Empty cells
100
+ * show a muted dash; failed cells keep their reason.
101
+ */
102
+ function RecordsTable({ rows, fields = {}, imageUrl, className }) {
103
+ const names = columnNames(fields, rows);
104
+ const table = /* @__PURE__ */ jsx("div", {
105
+ "data-p0": "records-table",
106
+ className: cn("pz:overflow-auto pz:font-sans pz:text-foreground pz:text-sm pz:antialiased", className),
107
+ children: /* @__PURE__ */ jsxs("table", {
108
+ "data-slot": "table",
109
+ className: "pz:w-full pz:border-separate pz:border-spacing-0 pz:caption-bottom",
110
+ children: [/* @__PURE__ */ jsx(TableHeader, {
111
+ className: "pz:sticky pz:top-0 pz:z-10 pz:bg-muted",
112
+ children: /* @__PURE__ */ jsx(TableRow, {
113
+ className: "pz:hover:bg-transparent",
114
+ children: names.map((name) => {
115
+ const column = fields[name];
116
+ const sample = rows.find((row) => row[name])?.[name];
117
+ const Icon = getFieldFormatIcon(sample?.format ?? column?.format ?? null, sample?.type ?? column?.type ?? "unknown");
118
+ const tally = column?.added_by?.ref === "input" ? null : tallyColumn(rows, name);
119
+ return /* @__PURE__ */ jsx(TableHead, {
120
+ className: "pz:h-8 pz:border-b pz:border-border pz:px-2.5 pz:text-xs pz:text-muted-foreground",
121
+ children: /* @__PURE__ */ jsxs("span", {
122
+ className: "pz:inline-flex pz:items-center pz:gap-1.5",
123
+ children: [
124
+ /* @__PURE__ */ jsx(Icon, { className: "pz:size-3.5 pz:shrink-0" }),
125
+ /* @__PURE__ */ jsx("span", { children: column?.label || name }),
126
+ tally && tally.total > 0 && /* @__PURE__ */ jsx(TallyBadge, { tally })
127
+ ]
128
+ })
129
+ }, name);
130
+ })
131
+ })
132
+ }), /* @__PURE__ */ jsx(TableBody, { children: rows.map((row, rowIndex) => /* @__PURE__ */ jsx(TableRow, {
133
+ className: "pz:hover:bg-muted/50",
134
+ children: names.map((name) => {
135
+ const cell = row[name];
136
+ const copyText = cell && cell.status === "completed" ? copyableText(cell.value) : null;
137
+ return /* @__PURE__ */ jsxs(TableCell, {
138
+ className: "pz:group/cell pz:relative pz:h-10 pz:border-b pz:border-border pz:px-2.5 pz:py-0",
139
+ children: [/* @__PURE__ */ jsx("div", {
140
+ className: cn("pz:flex pz:h-full pz:items-center pz:gap-2 pz:whitespace-nowrap", (cell?.status === "no_result" || cell?.status === "skipped") && "pz:text-muted-foreground pz:opacity-60"),
141
+ title: cell?.status === "failed" ? cell.reason?.message ?? void 0 : void 0,
142
+ children: !cell || isEmpty(cell) ? /* @__PURE__ */ jsx("span", {
143
+ className: "pz:text-muted-foreground/60",
144
+ children: "—"
145
+ }) : /* @__PURE__ */ jsx(RecordValue, {
146
+ field: {
147
+ ...cell,
148
+ type: cell.type ?? fields[name]?.type ?? "unknown",
149
+ format: cell.format ?? fields[name]?.format ?? null,
150
+ reason: cell.reason ?? null
151
+ },
152
+ afterGlyphs: cell.status === "failed" && /* @__PURE__ */ jsx(StateTile, { state: "failed" })
153
+ })
154
+ }), copyText !== null && /* @__PURE__ */ jsx(CopyCellButton, { text: copyText })]
155
+ }, name);
156
+ })
157
+ }, String(row.id?.value ?? rowIndex))) })]
158
+ })
159
+ });
160
+ return imageUrl ? /* @__PURE__ */ jsx(RecordImageUrlContext.Provider, {
161
+ value: imageUrl,
162
+ children: table
163
+ }) : table;
164
+ }
165
+
166
+ //#endregion
167
+ export { RecordsTable };
168
+ //# sourceMappingURL=records-table.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"records-table.mjs","names":[],"sources":["../../src/records/records-table.tsx"],"sourcesContent":["import type { FieldValue, RecordField } from \"@pipe0/base\";\nimport { useState } from \"react\";\nimport { Check, Copy } from \"../components/internal/icons.js\";\nimport { TableBody, TableCell, TableHead, TableHeader, TableRow } from \"../components/ui/table.js\";\nimport { cn } from \"../lib/utils.js\";\nimport { getFieldFormatIcon } from \"../utils/field-icons.js\";\nimport { RecordValue } from \"./record-value.js\";\nimport { RecordImageUrlContext, StateTile } from \"./record-widgets.js\";\n\n/**\n * A cell as both run responses carry it: a pipes record's field\n * (`RecordField`) or a search result's field, which leaves `type` and\n * `format` to its column's definition.\n */\nexport type RecordsTableCell = Pick<RecordField, \"value\" | \"status\" | \"widgets\"> &\n Partial<Pick<RecordField, \"type\" | \"format\" | \"reason\">>;\n\n/**\n * One row of native cells, field → cell: a pipes record's `fields`, or one\n * search result, exactly as the API returns it.\n */\nexport type RecordsTableRow = Record<string, RecordsTableCell | null | undefined>;\n\n/**\n * The column metadata the table reads. Pipes field definitions\n * (`PipesResponse.field_definitions`) and search field annotations\n * (`SearchResponse.field_definitions`) both fit as they are.\n */\nexport interface RecordsTableColumn {\n label?: string | null;\n type?: RecordField[\"type\"];\n format?: RecordField[\"format\"];\n order?: number | null;\n /** Input columns get no outcome count; pipe and search outputs do. */\n added_by?: { ref: string };\n}\n\nexport interface RecordsTableProps {\n rows: RecordsTableRow[];\n fields?: Record<string, RecordsTableColumn>;\n className?: string;\n /** Rewrites image URLs (avatars, logos, favicons), e.g. through an image proxy. */\n imageUrl?: (src: string) => string;\n}\n\n/** Declared columns by their display order, then any field only the rows carry. `id` is never shown. */\nfunction columnNames(\n fields: Record<string, RecordsTableColumn>,\n rows: RecordsTableRow[],\n): string[] {\n const declared = Object.entries(fields)\n .map(([name, column], index) => ({\n name,\n rank: column.order ?? Number.MAX_SAFE_INTEGER,\n index,\n }))\n .sort((a, b) => a.rank - b.rank || a.index - b.index)\n .map(({ name }) => name);\n const names = new Set(declared);\n for (const row of rows) for (const name of Object.keys(row)) names.add(name);\n names.delete(\"id\");\n return [...names];\n}\n\ntype Tally = { completed: number; no_result: number; failed: number; total: number };\n\nfunction tallyColumn(rows: RecordsTableRow[], name: string): Tally {\n const tally: Tally = { completed: 0, no_result: 0, failed: 0, total: 0 };\n for (const row of rows) {\n const status = row[name]?.status;\n if (!status) continue;\n tally.total += 1;\n if (status === \"completed\") tally.completed += 1;\n else if (status === \"no_result\") tally.no_result += 1;\n else if (status === \"failed\") tally.failed += 1;\n }\n return tally;\n}\n\nfunction isEmpty(cell: RecordsTableCell | null | undefined): boolean {\n return !cell || (cell.status === \"completed\" && (cell.value == null || cell.value === \"\"));\n}\n\n/** What a cell's copy button puts on the clipboard; null = no button (empty or trivial values). */\nfunction copyableText(value: FieldValue): string | null {\n if (value === null || value === \"\" || typeof value === \"boolean\") return null;\n if (typeof value === \"object\") return JSON.stringify(value, null, 2);\n return String(value);\n}\n\n// Hosts render the view in a sandboxed iframe whose permissions policy may\n// block the async Clipboard API; execCommand still copies on a user gesture.\nasync function writeClipboard(text: string): Promise<boolean> {\n try {\n await navigator.clipboard.writeText(text);\n return true;\n } catch {\n const area = document.createElement(\"textarea\");\n area.value = text;\n area.setAttribute(\"readonly\", \"\");\n area.style.position = \"fixed\";\n area.style.opacity = \"0\";\n document.body.appendChild(area);\n area.select();\n const copied = document.execCommand(\"copy\");\n area.remove();\n return copied;\n }\n}\n\nfunction CopyCellButton({ text }: { text: string }) {\n const [copied, setCopied] = useState(false);\n return (\n <button\n type=\"button\"\n aria-label=\"Copy value\"\n title={copied ? \"Copied\" : \"Copy\"}\n onClick={async (e) => {\n e.stopPropagation();\n if (!(await writeClipboard(text))) return;\n setCopied(true);\n setTimeout(() => setCopied(false), 1500);\n }}\n className={cn(\n \"pz:absolute pz:top-1/2 pz:right-1.5 pz:-translate-y-1/2 pz:inline-flex pz:size-6 pz:items-center pz:justify-center pz:rounded-md\",\n \"pz:border pz:border-border pz:bg-background pz:text-muted-foreground pz:shadow-xs pz:hover:text-foreground\",\n \"pz:opacity-0 pz:transition-opacity pz:group-hover/cell:opacity-100 pz:focus-visible:opacity-100\",\n copied && \"pz:opacity-100\",\n )}\n >\n {copied ? <Check className=\"pz:size-3.5\" /> : <Copy className=\"pz:size-3.5\" />}\n </button>\n );\n}\n\nfunction TallyBadge({ tally }: { tally: Tally }) {\n const title = [\n `${tally.completed} completed`,\n tally.no_result ? `${tally.no_result} no result` : null,\n tally.failed ? `${tally.failed} failed` : null,\n ]\n .filter(Boolean)\n .join(\" · \");\n return (\n <span\n title={title}\n className={cn(\n \"pz:ml-1.5 pz:rounded pz:px-1 pz:font-normal pz:text-[11px] pz:tabular-nums\",\n tally.failed > 0 ? \"pz:bg-failed pz:text-failed-foreground\" : \"pz:text-muted-foreground\",\n )}\n >\n {tally.completed}/{tally.total}\n </span>\n );\n}\n\n/**\n * Read-only table of a run's native rows: one column per field, each cell\n * rendered the way a pipe0 Sheet renders it, with its status. Empty cells\n * show a muted dash; failed cells keep their reason.\n */\nexport function RecordsTable({ rows, fields = {}, imageUrl, className }: RecordsTableProps) {\n const names = columnNames(fields, rows);\n\n const table = (\n // The root is the one scroll container, both axes: bound its height\n // (className) and the header stays put while rows and columns scroll.\n <div\n data-p0=\"records-table\"\n className={cn(\n \"pz:overflow-auto pz:font-sans pz:text-foreground pz:text-sm pz:antialiased\",\n className,\n )}\n >\n <table\n data-slot=\"table\"\n className=\"pz:w-full pz:border-separate pz:border-spacing-0 pz:caption-bottom\"\n >\n <TableHeader className=\"pz:sticky pz:top-0 pz:z-10 pz:bg-muted\">\n <TableRow className=\"pz:hover:bg-transparent\">\n {names.map((name) => {\n const column = fields[name];\n const sample = rows.find((row) => row[name])?.[name];\n const Icon = getFieldFormatIcon(\n sample?.format ?? column?.format ?? null,\n sample?.type ?? column?.type ?? \"unknown\",\n );\n const tally = column?.added_by?.ref === \"input\" ? null : tallyColumn(rows, name);\n return (\n <TableHead\n key={name}\n className=\"pz:h-8 pz:border-b pz:border-border pz:px-2.5 pz:text-xs pz:text-muted-foreground\"\n >\n <span className=\"pz:inline-flex pz:items-center pz:gap-1.5\">\n <Icon className=\"pz:size-3.5 pz:shrink-0\" />\n <span>{column?.label || name}</span>\n {tally && tally.total > 0 && <TallyBadge tally={tally} />}\n </span>\n </TableHead>\n );\n })}\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row, rowIndex) => (\n // A run's rows never reorder; search results carry no id.\n <TableRow key={String(row.id?.value ?? rowIndex)} className=\"pz:hover:bg-muted/50\">\n {names.map((name) => {\n const cell = row[name];\n const copyText =\n cell && cell.status === \"completed\" ? copyableText(cell.value) : null;\n return (\n <TableCell\n key={name}\n className=\"pz:group/cell pz:relative pz:h-10 pz:border-b pz:border-border pz:px-2.5 pz:py-0\"\n >\n <div\n className={cn(\n \"pz:flex pz:h-full pz:items-center pz:gap-2 pz:whitespace-nowrap\",\n // Quiet outcomes recede as a whole, as in a Sheet.\n (cell?.status === \"no_result\" || cell?.status === \"skipped\") &&\n \"pz:text-muted-foreground pz:opacity-60\",\n )}\n title={\n cell?.status === \"failed\" ? (cell.reason?.message ?? undefined) : undefined\n }\n >\n {!cell || isEmpty(cell) ? (\n <span className=\"pz:text-muted-foreground/60\">—</span>\n ) : (\n <RecordValue\n field={{\n ...cell,\n type: cell.type ?? fields[name]?.type ?? \"unknown\",\n format: cell.format ?? fields[name]?.format ?? null,\n reason: cell.reason ?? null,\n }}\n afterGlyphs={cell.status === \"failed\" && <StateTile state=\"failed\" />}\n />\n )}\n </div>\n {copyText !== null && <CopyCellButton text={copyText} />}\n </TableCell>\n );\n })}\n </TableRow>\n ))}\n </TableBody>\n </table>\n </div>\n );\n return imageUrl ? (\n <RecordImageUrlContext.Provider value={imageUrl}>{table}</RecordImageUrlContext.Provider>\n ) : (\n table\n );\n}\n"],"mappings":"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"}