@twentyfirstmedia/ui-core 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ICONS-LICENSE +24 -2
- package/dist/components/connected-data.d.ts +25 -0
- package/dist/components/connected-data.d.ts.map +1 -0
- package/dist/components/connected-data.js +264 -0
- package/dist/components/connected-data.js.map +1 -0
- package/dist/components/connectors.d.ts +22 -0
- package/dist/components/connectors.d.ts.map +1 -0
- package/dist/components/connectors.js +135 -0
- package/dist/components/connectors.js.map +1 -0
- package/dist/components/household.d.ts +19 -0
- package/dist/components/household.d.ts.map +1 -0
- package/dist/components/household.js +137 -0
- package/dist/components/household.js.map +1 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +4 -0
- package/dist/components/index.js.map +1 -1
- package/dist/components/life.d.ts +26 -0
- package/dist/components/life.d.ts.map +1 -0
- package/dist/components/life.js +187 -0
- package/dist/components/life.js.map +1 -0
- package/dist/components/types.d.ts +622 -0
- package/dist/components/types.d.ts.map +1 -1
- package/dist/context.d.ts +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +2 -1
- package/dist/context.js.map +1 -1
- package/dist/format.d.ts +5 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +31 -0
- package/dist/format.js.map +1 -1
- package/dist/icon-library.d.ts +235 -4
- package/dist/icon-library.d.ts.map +1 -1
- package/dist/icon-library.js +467 -15
- package/dist/icon-library.js.map +1 -1
- package/dist/icons.d.ts +2 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/strings.d.ts +184 -0
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +182 -1
- package/dist/strings.js.map +1 -1
- package/dist/themes.css +16438 -11238
- package/package.json +1 -1
- package/src/components/connected-data.tsx +783 -0
- package/src/components/connectors.tsx +426 -0
- package/src/components/household.tsx +489 -0
- package/src/components/index.ts +39 -0
- package/src/components/life.tsx +679 -0
- package/src/components/types.ts +602 -0
- package/src/context.ts +10 -1
- package/src/format.ts +33 -0
- package/src/icon-library.ts +476 -19
- package/src/icons.ts +2 -0
- package/src/index.ts +5 -0
- package/src/strings.ts +518 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cx, initials } from "../cx.js";
|
|
4
|
+
import { useFormat, useStrings } from "../context.js";
|
|
5
|
+
import { CONNECTOR_STRINGS } from "../strings.js";
|
|
6
|
+
import { Icon } from "../Icon.js";
|
|
7
|
+
import { iconData, registerIcons } from "../icons.js";
|
|
8
|
+
import { refreshCwOff } from "../icon-library.js";
|
|
9
|
+
import { Badge, Button, Chip } from "./foundation.js";
|
|
10
|
+
import { SearchField } from "./inputs.js";
|
|
11
|
+
import { EmptyState } from "./feedback.js";
|
|
12
|
+
import type {
|
|
13
|
+
ConnectionListProps,
|
|
14
|
+
AgentActivityProps,
|
|
15
|
+
AgentAction,
|
|
16
|
+
ConnectorGalleryProps,
|
|
17
|
+
Connector,
|
|
18
|
+
ServiceMarkProps,
|
|
19
|
+
SyncStatusProps,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
// default icons of these components come from the icon library
|
|
23
|
+
registerIcons({ refreshCwOff });
|
|
24
|
+
|
|
25
|
+
const MARK_SIZE = { sm: 14, md: 20, lg: 26 } as const;
|
|
26
|
+
|
|
27
|
+
function isWide(name: string): boolean {
|
|
28
|
+
const d = iconData(name);
|
|
29
|
+
return typeof d === "object" && d.wide === true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A service's mark: its brand logo, your own logo, or a monogram when neither exists, with an
|
|
34
|
+
* optional state dot. Use it wherever a connected service appears (lists, chips, activity).
|
|
35
|
+
*/
|
|
36
|
+
export function ServiceMark(p: ServiceMarkProps) {
|
|
37
|
+
const t = useStrings(CONNECTOR_STRINGS);
|
|
38
|
+
const size = p.size || "md";
|
|
39
|
+
const label = p.status ? `${p.name}, ${t.serviceStates[p.status]}` : p.name;
|
|
40
|
+
return (
|
|
41
|
+
<span
|
|
42
|
+
style={p.style}
|
|
43
|
+
className={cx(
|
|
44
|
+
"tf-svc",
|
|
45
|
+
"tf-svc-" + size,
|
|
46
|
+
"tf-t-" + (p.tint || "plain"),
|
|
47
|
+
!p.icon && !p.logo && "is-monogram",
|
|
48
|
+
p.className,
|
|
49
|
+
)}
|
|
50
|
+
role={p.decorative ? undefined : "img"}
|
|
51
|
+
aria-label={p.decorative ? undefined : label}
|
|
52
|
+
aria-hidden={p.decorative ? true : undefined}
|
|
53
|
+
>
|
|
54
|
+
{p.logo ||
|
|
55
|
+
(p.icon ? (
|
|
56
|
+
isWide(p.icon) ? (
|
|
57
|
+
<Icon name={p.icon} style={{ width: "88%", height: "88%" }} />
|
|
58
|
+
) : (
|
|
59
|
+
<Icon name={p.icon} size={MARK_SIZE[size]} />
|
|
60
|
+
)
|
|
61
|
+
) : (
|
|
62
|
+
<span className="tf-svc-mono">{(p.monogram || initials(p.name)).slice(0, 2)}</span>
|
|
63
|
+
))}
|
|
64
|
+
{p.status && <span className={cx("tf-svc-dot", "is-" + p.status)} />}
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every service an assistant can connect to, grouped by category, with search and category chips.
|
|
71
|
+
* Each tile says whether the service is connected, needs a reconnect or comes later.
|
|
72
|
+
*/
|
|
73
|
+
export function ConnectorGallery(p: ConnectorGalleryProps) {
|
|
74
|
+
const t = useStrings(CONNECTOR_STRINGS);
|
|
75
|
+
const [query, setQuery] = React.useState("");
|
|
76
|
+
const [category, setCategory] = React.useState<string | null>(null);
|
|
77
|
+
const order = React.useMemo(() => {
|
|
78
|
+
const seen = [...(p.categories || [])];
|
|
79
|
+
for (const c of p.connectors) if (!seen.includes(c.category)) seen.push(c.category);
|
|
80
|
+
return seen.filter((c) => p.connectors.some((x) => x.category === c));
|
|
81
|
+
}, [p.connectors, p.categories]);
|
|
82
|
+
const q = query.trim().toLowerCase();
|
|
83
|
+
const shown = p.connectors.filter(
|
|
84
|
+
(c) =>
|
|
85
|
+
(!category || c.category === category) &&
|
|
86
|
+
(!q || c.name.toLowerCase().includes(q) || c.category.toLowerCase().includes(q)),
|
|
87
|
+
);
|
|
88
|
+
const filters = p.filters !== false;
|
|
89
|
+
return (
|
|
90
|
+
<div style={p.style} className={cx("tf-connectors", p.className)}>
|
|
91
|
+
{filters && (
|
|
92
|
+
<div className="tf-connectors-filters">
|
|
93
|
+
<SearchField
|
|
94
|
+
label={t.searchConnectors}
|
|
95
|
+
placeholder={t.searchConnectors}
|
|
96
|
+
value={query}
|
|
97
|
+
onChange={(e) => setQuery(e.currentTarget.value)}
|
|
98
|
+
/>
|
|
99
|
+
<div className="tf-connectors-chips" role="group" aria-label={t.connectors}>
|
|
100
|
+
<Chip selected={!category} onClick={() => setCategory(null)}>
|
|
101
|
+
{t.allCategories}
|
|
102
|
+
</Chip>
|
|
103
|
+
{order.map((c) => (
|
|
104
|
+
<Chip key={c} selected={category === c} onClick={() => setCategory(c)}>
|
|
105
|
+
{c}
|
|
106
|
+
</Chip>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
{shown.length === 0 ? (
|
|
112
|
+
<EmptyState icon="search" title={t.noConnectorMatch(query.trim())} />
|
|
113
|
+
) : (
|
|
114
|
+
order
|
|
115
|
+
.filter((c) => shown.some((x) => x.category === c))
|
|
116
|
+
.map((c) => (
|
|
117
|
+
<section key={c} className="tf-connectors-group" aria-label={c}>
|
|
118
|
+
<h3 className="tf-connectors-title">{c}</h3>
|
|
119
|
+
<ul className="tf-connectors-grid">
|
|
120
|
+
{shown
|
|
121
|
+
.filter((x) => x.category === c)
|
|
122
|
+
.map((x) => (
|
|
123
|
+
<ConnectorTile key={x.id} c={x} onConnect={p.onConnect} onOpen={p.onOpen} />
|
|
124
|
+
))}
|
|
125
|
+
</ul>
|
|
126
|
+
</section>
|
|
127
|
+
))
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ConnectorTile({
|
|
134
|
+
c,
|
|
135
|
+
onConnect,
|
|
136
|
+
onOpen,
|
|
137
|
+
}: {
|
|
138
|
+
c: Connector;
|
|
139
|
+
onConnect?: (id: string) => void;
|
|
140
|
+
onOpen?: (id: string) => void;
|
|
141
|
+
}) {
|
|
142
|
+
const t = useStrings(CONNECTOR_STRINGS);
|
|
143
|
+
const state = c.state || "available";
|
|
144
|
+
return (
|
|
145
|
+
<li className={cx("tf-connector", "is-" + state)}>
|
|
146
|
+
<div className="tf-connector-head">
|
|
147
|
+
<ServiceMark
|
|
148
|
+
name={c.name}
|
|
149
|
+
icon={c.icon}
|
|
150
|
+
logo={c.logo}
|
|
151
|
+
monogram={c.monogram}
|
|
152
|
+
tint={c.tint}
|
|
153
|
+
status={state === "connected" ? "connected" : state === "error" ? "error" : undefined}
|
|
154
|
+
decorative
|
|
155
|
+
/>
|
|
156
|
+
<span className="tf-option-text">
|
|
157
|
+
<b>{c.name}</b>
|
|
158
|
+
{c.account && <small>{c.account}</small>}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
{c.description && <p className="tf-connector-desc">{c.description}</p>}
|
|
162
|
+
{c.note && <p className="tf-connector-note">{c.note}</p>}
|
|
163
|
+
<div className="tf-connector-foot">
|
|
164
|
+
{state === "connected" && (
|
|
165
|
+
<>
|
|
166
|
+
<Badge tone="success" dot>
|
|
167
|
+
{t.serviceStates.connected}
|
|
168
|
+
</Badge>
|
|
169
|
+
{onOpen && (
|
|
170
|
+
<Button
|
|
171
|
+
variant="ghost"
|
|
172
|
+
size="sm"
|
|
173
|
+
aria-label={t.openService(c.name)}
|
|
174
|
+
onClick={() => onOpen(c.id)}
|
|
175
|
+
>
|
|
176
|
+
{t.openAction}
|
|
177
|
+
</Button>
|
|
178
|
+
)}
|
|
179
|
+
</>
|
|
180
|
+
)}
|
|
181
|
+
{state === "error" && (
|
|
182
|
+
<>
|
|
183
|
+
<Badge tone="danger" dot>
|
|
184
|
+
{t.serviceStates.error}
|
|
185
|
+
</Badge>
|
|
186
|
+
<Button
|
|
187
|
+
variant="secondary"
|
|
188
|
+
size="sm"
|
|
189
|
+
aria-label={t.reconnectService(c.name)}
|
|
190
|
+
onClick={() => (onOpen || onConnect)?.(c.id)}
|
|
191
|
+
>
|
|
192
|
+
{t.reconnect}
|
|
193
|
+
</Button>
|
|
194
|
+
</>
|
|
195
|
+
)}
|
|
196
|
+
{state === "soon" && <Badge tone="neutral">{t.comingSoon}</Badge>}
|
|
197
|
+
{state === "available" && (
|
|
198
|
+
<Button
|
|
199
|
+
variant="secondary"
|
|
200
|
+
size="sm"
|
|
201
|
+
icon="plus"
|
|
202
|
+
aria-label={t.connectService(c.name)}
|
|
203
|
+
onClick={() => onConnect?.(c.id)}
|
|
204
|
+
>
|
|
205
|
+
{t.connect}
|
|
206
|
+
</Button>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</li>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** When a connected service last synced, what it brought and when it syncs next. */
|
|
214
|
+
export function SyncStatus(p: SyncStatusProps) {
|
|
215
|
+
const t = useStrings(CONNECTOR_STRINGS);
|
|
216
|
+
const { formatRelative, formatNumber } = useFormat();
|
|
217
|
+
const now = p.now ?? Date.now();
|
|
218
|
+
const text =
|
|
219
|
+
p.state === "syncing"
|
|
220
|
+
? t.syncingNow
|
|
221
|
+
: p.state === "error"
|
|
222
|
+
? t.syncFailed
|
|
223
|
+
: p.state === "paused"
|
|
224
|
+
? t.syncPaused
|
|
225
|
+
: p.state === "never" || p.last == null
|
|
226
|
+
? t.neverSynced
|
|
227
|
+
: t.syncedAgo(formatRelative(p.last, now));
|
|
228
|
+
const icon =
|
|
229
|
+
p.state === "error"
|
|
230
|
+
? "x"
|
|
231
|
+
: p.state === "paused"
|
|
232
|
+
? "pause"
|
|
233
|
+
: p.state === "never"
|
|
234
|
+
? "refreshCwOff"
|
|
235
|
+
: p.state === "syncing"
|
|
236
|
+
? "refresh"
|
|
237
|
+
: "check";
|
|
238
|
+
return (
|
|
239
|
+
<div
|
|
240
|
+
style={p.style}
|
|
241
|
+
className={cx("tf-sync", "is-" + p.state, p.compact && "compact", p.className)}
|
|
242
|
+
role="status"
|
|
243
|
+
>
|
|
244
|
+
<div className="tf-sync-line">
|
|
245
|
+
<span className="tf-sync-icon" aria-hidden>
|
|
246
|
+
<Icon name={icon} size={14} />
|
|
247
|
+
</span>
|
|
248
|
+
<span className="tf-sync-text">
|
|
249
|
+
{text}
|
|
250
|
+
{p.state === "error" && p.last != null && !p.compact && (
|
|
251
|
+
<small> · {t.syncedAgo(formatRelative(p.last, now))}</small>
|
|
252
|
+
)}
|
|
253
|
+
</span>
|
|
254
|
+
{!p.compact && p.next != null && p.state !== "paused" && p.state !== "syncing" && (
|
|
255
|
+
<small className="tf-sync-next">{t.nextSync(formatRelative(p.next, now))}</small>
|
|
256
|
+
)}
|
|
257
|
+
{p.onSync && p.state !== "syncing" && (
|
|
258
|
+
<Button variant="ghost" size="sm" icon="refresh" onClick={p.onSync}>
|
|
259
|
+
{t.syncNow}
|
|
260
|
+
</Button>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
{!p.compact && p.error && <p className="tf-sync-error">{p.error}</p>}
|
|
264
|
+
{!p.compact && p.counts && p.counts.length > 0 && (
|
|
265
|
+
<ul className="tf-sync-counts">
|
|
266
|
+
{p.counts.map((c) => (
|
|
267
|
+
<li key={c.label}>
|
|
268
|
+
<b>{formatNumber(c.value)}</b> {c.label}
|
|
269
|
+
</li>
|
|
270
|
+
))}
|
|
271
|
+
</ul>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const DAY = 86400000;
|
|
278
|
+
function dayStart(v: Date | string | number): number {
|
|
279
|
+
const d = new Date(v);
|
|
280
|
+
return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* What agents did with your connected services, newest first and grouped by day: what they read,
|
|
285
|
+
* what they changed and what waits for your approval, with "Undo" where it can be undone.
|
|
286
|
+
*/
|
|
287
|
+
export function AgentActivity(p: AgentActivityProps) {
|
|
288
|
+
const t = useStrings(CONNECTOR_STRINGS);
|
|
289
|
+
const { formatDate, locale } = useFormat();
|
|
290
|
+
const now = p.now ?? Date.now();
|
|
291
|
+
const sorted = [...p.items].sort((a, b) => +new Date(b.at) - +new Date(a.at));
|
|
292
|
+
const groups: { key: number; items: AgentAction[] }[] = [];
|
|
293
|
+
for (const it of sorted) {
|
|
294
|
+
const k = dayStart(it.at);
|
|
295
|
+
const last = groups[groups.length - 1];
|
|
296
|
+
if (last && last.key === k) last.items.push(it);
|
|
297
|
+
else groups.push({ key: k, items: [it] });
|
|
298
|
+
}
|
|
299
|
+
const heading = (k: number, sample: AgentAction["at"]) => {
|
|
300
|
+
const diff = Math.round((k - dayStart(now)) / DAY);
|
|
301
|
+
if (diff === 0 || diff === -1) {
|
|
302
|
+
try {
|
|
303
|
+
const w = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(diff, "day");
|
|
304
|
+
return w.charAt(0).toLocaleUpperCase(locale) + w.slice(1);
|
|
305
|
+
} catch {
|
|
306
|
+
/* fall through to the date */
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return formatDate(sample, { weekday: "long", day: "numeric", month: "long" });
|
|
310
|
+
};
|
|
311
|
+
return (
|
|
312
|
+
<div style={p.style} className={cx("tf-agentlog", p.className)}>
|
|
313
|
+
{groups.map((g) => (
|
|
314
|
+
<section
|
|
315
|
+
key={g.key}
|
|
316
|
+
className="tf-agentlog-day"
|
|
317
|
+
aria-label={heading(g.key, g.items[0]!.at)}
|
|
318
|
+
>
|
|
319
|
+
<h3 className="tf-agentlog-title" aria-hidden>
|
|
320
|
+
{heading(g.key, g.items[0]!.at)}
|
|
321
|
+
</h3>
|
|
322
|
+
<ul aria-label={p.label || t.agentActivity}>
|
|
323
|
+
{g.items.map((a) => {
|
|
324
|
+
const state = a.state || "done";
|
|
325
|
+
const kind = a.kind || "read";
|
|
326
|
+
return (
|
|
327
|
+
<li key={a.id} className={cx("tf-agentlog-item", "is-" + state, "kind-" + kind)}>
|
|
328
|
+
{a.service ? (
|
|
329
|
+
<ServiceMark
|
|
330
|
+
name={a.service.name}
|
|
331
|
+
icon={a.service.icon}
|
|
332
|
+
logo={a.service.logo}
|
|
333
|
+
size="sm"
|
|
334
|
+
/>
|
|
335
|
+
) : (
|
|
336
|
+
<span className="tf-svc tf-svc-sm tf-t-plain" aria-hidden>
|
|
337
|
+
<Icon name="bot" size={14} />
|
|
338
|
+
</span>
|
|
339
|
+
)}
|
|
340
|
+
<div className="tf-agentlog-main">
|
|
341
|
+
<p className="tf-agentlog-text">{a.text}</p>
|
|
342
|
+
{a.detail && <p className="tf-agentlog-detail">{a.detail}</p>}
|
|
343
|
+
<p className="tf-agentlog-meta">
|
|
344
|
+
{[a.agent, formatDate(a.at, { hour: "numeric", minute: "2-digit" })]
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.join(" · ")}
|
|
347
|
+
<span className={cx("tf-agentlog-kind", "kind-" + kind)}>
|
|
348
|
+
{t.actionKinds[kind]}
|
|
349
|
+
</span>
|
|
350
|
+
{state !== "done" && (
|
|
351
|
+
<span className={cx("tf-agentlog-state", "is-" + state)}>
|
|
352
|
+
{t.actionStates[state]}
|
|
353
|
+
</span>
|
|
354
|
+
)}
|
|
355
|
+
</p>
|
|
356
|
+
</div>
|
|
357
|
+
{state === "waiting" && p.onReview && (
|
|
358
|
+
<Button variant="primary" size="sm" onClick={() => p.onReview?.(a.id)}>
|
|
359
|
+
{t.reviewAction}
|
|
360
|
+
</Button>
|
|
361
|
+
)}
|
|
362
|
+
{state === "done" && kind !== "read" && p.onUndo && (
|
|
363
|
+
<Button variant="ghost" size="sm" icon="undo" onClick={() => p.onUndo?.(a.id)}>
|
|
364
|
+
{t.undo}
|
|
365
|
+
</Button>
|
|
366
|
+
)}
|
|
367
|
+
</li>
|
|
368
|
+
);
|
|
369
|
+
})}
|
|
370
|
+
</ul>
|
|
371
|
+
</section>
|
|
372
|
+
))}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The services you are connected to: account, state, how fresh the data is, and Manage. */
|
|
378
|
+
export function ConnectionList(p: ConnectionListProps) {
|
|
379
|
+
const t = useStrings(CONNECTOR_STRINGS);
|
|
380
|
+
return (
|
|
381
|
+
<ul
|
|
382
|
+
style={p.style}
|
|
383
|
+
className={cx("tf-connlist", p.className)}
|
|
384
|
+
aria-label={p.label || t.connectionsLabel}
|
|
385
|
+
>
|
|
386
|
+
{p.items.map((c) => (
|
|
387
|
+
<li key={c.id} className={cx("tf-connlist-item", "is-" + c.state)}>
|
|
388
|
+
<ServiceMark
|
|
389
|
+
name={c.name}
|
|
390
|
+
icon={c.icon}
|
|
391
|
+
logo={c.logo}
|
|
392
|
+
monogram={c.monogram}
|
|
393
|
+
tint={c.tint}
|
|
394
|
+
status={c.state}
|
|
395
|
+
/>
|
|
396
|
+
<span className="tf-option-text">
|
|
397
|
+
<b>{c.name}</b>
|
|
398
|
+
{c.account && <small>{c.account}</small>}
|
|
399
|
+
</span>
|
|
400
|
+
{c.sync && <SyncStatus state={c.sync.state} last={c.sync.last} now={p.now} compact />}
|
|
401
|
+
{c.state === "error" && p.onReconnect ? (
|
|
402
|
+
<Button
|
|
403
|
+
variant="secondary"
|
|
404
|
+
size="sm"
|
|
405
|
+
aria-label={t.reconnectService(c.name)}
|
|
406
|
+
onClick={() => p.onReconnect?.(c.id)}
|
|
407
|
+
>
|
|
408
|
+
{t.reconnect}
|
|
409
|
+
</Button>
|
|
410
|
+
) : (
|
|
411
|
+
p.onManage && (
|
|
412
|
+
<Button
|
|
413
|
+
variant="ghost"
|
|
414
|
+
size="sm"
|
|
415
|
+
aria-label={t.manageService(c.name)}
|
|
416
|
+
onClick={() => p.onManage?.(c.id)}
|
|
417
|
+
>
|
|
418
|
+
{t.manageAction}
|
|
419
|
+
</Button>
|
|
420
|
+
)
|
|
421
|
+
)}
|
|
422
|
+
</li>
|
|
423
|
+
))}
|
|
424
|
+
</ul>
|
|
425
|
+
);
|
|
426
|
+
}
|