@workflow/web-shared 4.1.0-beta.63 → 4.1.0-beta.65
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/README.md +4 -0
- package/dist/components/event-list-view.d.ts +12 -1
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +233 -91
- package/dist/components/event-list-view.js.map +1 -1
- 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/sidebar/entity-detail-panel.d.ts +3 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +4 -14
- package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
- package/dist/components/stream-viewer.d.ts +3 -1
- package/dist/components/stream-viewer.d.ts.map +1 -1
- package/dist/components/stream-viewer.js +23 -28
- package/dist/components/stream-viewer.js.map +1 -1
- package/dist/components/ui/decrypt-button.d.ts +15 -0
- package/dist/components/ui/decrypt-button.d.ts.map +1 -0
- package/dist/components/ui/decrypt-button.js +12 -0
- package/dist/components/ui/decrypt-button.js.map +1 -0
- package/dist/components/ui/error-stack-block.d.ts +3 -4
- package/dist/components/ui/error-stack-block.d.ts.map +1 -1
- package/dist/components/ui/error-stack-block.js +18 -9
- package/dist/components/ui/error-stack-block.js.map +1 -1
- package/dist/components/ui/load-more-button.d.ts +13 -0
- package/dist/components/ui/load-more-button.d.ts.map +1 -0
- package/dist/components/ui/load-more-button.js +12 -0
- package/dist/components/ui/load-more-button.js.map +1 -0
- package/dist/components/ui/menu-dropdown.d.ts +16 -0
- package/dist/components/ui/menu-dropdown.d.ts.map +1 -0
- package/dist/components/ui/menu-dropdown.js +46 -0
- package/dist/components/ui/menu-dropdown.js.map +1 -0
- package/dist/components/ui/spinner.d.ts +9 -0
- package/dist/components/ui/spinner.d.ts.map +1 -0
- package/dist/components/ui/spinner.js +57 -0
- package/dist/components/ui/spinner.js.map +1 -0
- package/dist/components/workflow-trace-view.d.ts +3 -1
- package/dist/components/workflow-trace-view.d.ts.map +1 -1
- package/dist/components/workflow-trace-view.js +7 -6
- package/dist/components/workflow-trace-view.js.map +1 -1
- package/package.json +3 -3
- package/src/components/event-list-view.tsx +398 -141
- package/src/components/index.ts +4 -0
- package/src/components/sidebar/entity-detail-panel.tsx +9 -25
- package/src/components/stream-viewer.tsx +52 -63
- package/src/components/ui/decrypt-button.tsx +69 -0
- package/src/components/ui/error-stack-block.tsx +26 -16
- package/src/components/ui/load-more-button.tsx +38 -0
- package/src/components/ui/menu-dropdown.tsx +111 -0
- package/src/components/ui/spinner.tsx +76 -0
- package/src/components/workflow-trace-view.tsx +15 -22
package/src/components/index.ts
CHANGED
|
@@ -22,4 +22,8 @@ export type {
|
|
|
22
22
|
export { type StreamChunk, StreamViewer } from './stream-viewer';
|
|
23
23
|
export type { Span, SpanEvent } from './trace-viewer/types';
|
|
24
24
|
export { DataInspector, type DataInspectorProps } from './ui/data-inspector';
|
|
25
|
+
export { DecryptButton } from './ui/decrypt-button';
|
|
26
|
+
export { LoadMoreButton } from './ui/load-more-button';
|
|
27
|
+
export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
|
|
28
|
+
export { Spinner } from './ui/spinner';
|
|
25
29
|
export { WorkflowTraceViewer } from './workflow-trace-view';
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
|
-
import {
|
|
5
|
+
import { Send, Zap } from 'lucide-react';
|
|
6
6
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
7
7
|
import { toast } from 'sonner';
|
|
8
8
|
import { isEncryptedMarker } from '../../lib/hydration';
|
|
9
|
+
import { DecryptButton } from '../ui/decrypt-button';
|
|
9
10
|
import { AttributePanel } from './attribute-panel';
|
|
10
11
|
import { EventsList } from './events-list';
|
|
11
12
|
import { ResolveHookModal } from './resolve-hook-modal';
|
|
@@ -64,6 +65,7 @@ export function EntityDetailPanel({
|
|
|
64
65
|
onResolveHook,
|
|
65
66
|
encryptionKey,
|
|
66
67
|
onDecrypt,
|
|
68
|
+
isDecrypting = false,
|
|
67
69
|
selectedSpan,
|
|
68
70
|
}: {
|
|
69
71
|
run: WorkflowRun;
|
|
@@ -97,6 +99,8 @@ export function EntityDetailPanel({
|
|
|
97
99
|
encryptionKey?: Uint8Array;
|
|
98
100
|
/** Callback to initiate decryption of encrypted run data */
|
|
99
101
|
onDecrypt?: () => void;
|
|
102
|
+
/** Whether the encryption key is currently being fetched */
|
|
103
|
+
isDecrypting?: boolean;
|
|
100
104
|
/** Info about the currently selected span from the trace viewer */
|
|
101
105
|
selectedSpan: SelectedSpanInfo | null;
|
|
102
106
|
}): React.JSX.Element | null {
|
|
@@ -381,31 +385,11 @@ export function EntityDetailPanel({
|
|
|
381
385
|
</p>
|
|
382
386
|
</div>
|
|
383
387
|
{(hasEncryptedFields || encryptionKey) && onDecrypt && (
|
|
384
|
-
<
|
|
385
|
-
|
|
388
|
+
<DecryptButton
|
|
389
|
+
decrypted={!!encryptionKey}
|
|
390
|
+
loading={isDecrypting}
|
|
386
391
|
onClick={onDecrypt}
|
|
387
|
-
|
|
388
|
-
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors flex-shrink-0"
|
|
389
|
-
style={{
|
|
390
|
-
borderColor: encryptionKey
|
|
391
|
-
? 'var(--ds-green-400)'
|
|
392
|
-
: 'var(--ds-gray-300)',
|
|
393
|
-
color: encryptionKey
|
|
394
|
-
? 'var(--ds-green-900)'
|
|
395
|
-
: 'var(--ds-gray-900)',
|
|
396
|
-
backgroundColor: encryptionKey
|
|
397
|
-
? 'var(--ds-green-100)'
|
|
398
|
-
: 'var(--ds-background-100)',
|
|
399
|
-
cursor: encryptionKey ? 'default' : 'pointer',
|
|
400
|
-
}}
|
|
401
|
-
>
|
|
402
|
-
{encryptionKey ? (
|
|
403
|
-
<Unlock className="h-3 w-3" />
|
|
404
|
-
) : (
|
|
405
|
-
<Lock className="h-3 w-3" />
|
|
406
|
-
)}
|
|
407
|
-
{encryptionKey ? 'Decrypted' : 'Decrypt'}
|
|
408
|
-
</button>
|
|
392
|
+
/>
|
|
409
393
|
)}
|
|
410
394
|
</div>
|
|
411
395
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, {
|
|
3
|
+
import React, { useEffect, useRef } from 'react';
|
|
4
|
+
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
|
4
5
|
import { DataInspector } from './ui/data-inspector';
|
|
5
6
|
import { Skeleton } from './ui/skeleton';
|
|
6
7
|
|
|
@@ -46,6 +47,8 @@ interface StreamViewerProps {
|
|
|
46
47
|
error?: string | null;
|
|
47
48
|
/** True while the initial stream connection is being established */
|
|
48
49
|
isLoading?: boolean;
|
|
50
|
+
/** Called when the user scrolls near the bottom, for triggering pagination */
|
|
51
|
+
onScrollEnd?: () => void;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -114,45 +117,41 @@ function StreamSkeleton() {
|
|
|
114
117
|
* of complex types (Map, Set, Date, custom classes, etc.).
|
|
115
118
|
*/
|
|
116
119
|
export function StreamViewer({
|
|
117
|
-
streamId,
|
|
120
|
+
streamId: _streamId,
|
|
118
121
|
chunks,
|
|
119
122
|
isLive,
|
|
120
123
|
error,
|
|
121
124
|
isLoading,
|
|
125
|
+
onScrollEnd,
|
|
122
126
|
}: StreamViewerProps) {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
const checkScrollPosition = useCallback(() => {
|
|
127
|
-
if (scrollRef.current) {
|
|
128
|
-
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
|
129
|
-
const isAtBottom = scrollHeight - scrollTop - clientHeight < 10;
|
|
130
|
-
setHasMoreBelow(!isAtBottom && scrollHeight > clientHeight);
|
|
131
|
-
}
|
|
132
|
-
}, []);
|
|
127
|
+
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
128
|
+
const prevChunkCountRef = useRef(0);
|
|
133
129
|
|
|
134
|
-
//
|
|
130
|
+
// Auto-scroll to bottom when new chunks arrive (live streaming)
|
|
135
131
|
useEffect(() => {
|
|
136
|
-
if (
|
|
137
|
-
|
|
132
|
+
if (chunks.length > prevChunkCountRef.current && chunks.length > 0) {
|
|
133
|
+
virtuosoRef.current?.scrollToIndex({
|
|
134
|
+
index: chunks.length - 1,
|
|
135
|
+
align: 'end',
|
|
136
|
+
});
|
|
138
137
|
}
|
|
139
|
-
|
|
140
|
-
}, [chunks.length
|
|
138
|
+
prevChunkCountRef.current = chunks.length;
|
|
139
|
+
}, [chunks.length]);
|
|
141
140
|
|
|
142
141
|
// Show skeleton when loading and no chunks have arrived yet
|
|
143
142
|
if (isLoading && chunks.length === 0) {
|
|
144
143
|
return (
|
|
145
|
-
<div className="flex flex-col h-full
|
|
144
|
+
<div className="flex flex-col h-full">
|
|
146
145
|
<StreamSkeleton />
|
|
147
146
|
</div>
|
|
148
147
|
);
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
return (
|
|
152
|
-
<div className="flex flex-col h-full
|
|
151
|
+
<div className="flex flex-col h-full">
|
|
153
152
|
{/* Live indicator */}
|
|
154
153
|
{isLive && (
|
|
155
|
-
<div className="flex items-center gap-1.5 mb-
|
|
154
|
+
<div className="flex items-center gap-1.5 mb-2 px-1">
|
|
156
155
|
<span
|
|
157
156
|
className="inline-block w-2 h-2 rounded-full"
|
|
158
157
|
style={{ backgroundColor: 'var(--ds-green-600)' }}
|
|
@@ -182,52 +181,42 @@ export function StreamViewer({
|
|
|
182
181
|
)}
|
|
183
182
|
|
|
184
183
|
{/* Content */}
|
|
185
|
-
<div className="
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
>
|
|
200
|
-
<div>Error reading stream:</div>
|
|
201
|
-
<div>{error}</div>
|
|
202
|
-
</div>
|
|
203
|
-
) : chunks.length === 0 ? (
|
|
204
|
-
<div
|
|
205
|
-
className="text-[11px] rounded-md border p-3"
|
|
206
|
-
style={{
|
|
207
|
-
borderColor: 'var(--ds-gray-300)',
|
|
208
|
-
backgroundColor: 'var(--ds-gray-100)',
|
|
209
|
-
color: 'var(--ds-gray-600)',
|
|
210
|
-
}}
|
|
211
|
-
>
|
|
212
|
-
{isLive ? 'Waiting for stream data...' : 'Stream is empty'}
|
|
213
|
-
</div>
|
|
214
|
-
) : (
|
|
215
|
-
chunks.map((chunk, index) => (
|
|
216
|
-
<ChunkRow
|
|
217
|
-
key={`${streamId}-chunk-${chunk.id}`}
|
|
218
|
-
chunk={chunk}
|
|
219
|
-
index={index}
|
|
220
|
-
/>
|
|
221
|
-
))
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
{hasMoreBelow && (
|
|
184
|
+
<div className="flex-1 min-h-0">
|
|
185
|
+
{error ? (
|
|
186
|
+
<div
|
|
187
|
+
className="text-[11px] rounded-md border p-3"
|
|
188
|
+
style={{
|
|
189
|
+
borderColor: 'var(--ds-red-300)',
|
|
190
|
+
backgroundColor: 'var(--ds-red-100)',
|
|
191
|
+
color: 'var(--ds-red-700)',
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<div>Error reading stream:</div>
|
|
195
|
+
<div>{error}</div>
|
|
196
|
+
</div>
|
|
197
|
+
) : chunks.length === 0 ? (
|
|
225
198
|
<div
|
|
226
|
-
className="
|
|
199
|
+
className="text-[11px] rounded-md border p-3"
|
|
227
200
|
style={{
|
|
228
|
-
|
|
229
|
-
|
|
201
|
+
borderColor: 'var(--ds-gray-300)',
|
|
202
|
+
backgroundColor: 'var(--ds-gray-100)',
|
|
203
|
+
color: 'var(--ds-gray-600)',
|
|
230
204
|
}}
|
|
205
|
+
>
|
|
206
|
+
{isLive ? 'Waiting for stream data...' : 'Stream is empty'}
|
|
207
|
+
</div>
|
|
208
|
+
) : (
|
|
209
|
+
<Virtuoso
|
|
210
|
+
ref={virtuosoRef}
|
|
211
|
+
totalCount={chunks.length}
|
|
212
|
+
overscan={10}
|
|
213
|
+
endReached={() => onScrollEnd?.()}
|
|
214
|
+
itemContent={(index) => (
|
|
215
|
+
<div style={{ paddingBottom: 8 }}>
|
|
216
|
+
<ChunkRow chunk={chunks[index]} index={index} />
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
style={{ flex: 1, minHeight: 0 }}
|
|
231
220
|
/>
|
|
232
221
|
)}
|
|
233
222
|
</div>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Spinner } from './spinner';
|
|
4
|
+
|
|
5
|
+
const STYLES = `.wf-decrypt-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
|
|
6
|
+
|
|
7
|
+
interface DecryptButtonProps {
|
|
8
|
+
/** Whether an encryption key has been obtained (decryption is active). */
|
|
9
|
+
decrypted?: boolean;
|
|
10
|
+
/** Whether the key is currently being fetched. */
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
/** Called when the user clicks to initiate decryption. */
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Decrypt/Decrypted button using Geist secondary style.
|
|
18
|
+
* Three states: idle (secondary gray), decrypting (spinner), decrypted (green success).
|
|
19
|
+
*/
|
|
20
|
+
export function DecryptButton({
|
|
21
|
+
decrypted = false,
|
|
22
|
+
loading = false,
|
|
23
|
+
onClick,
|
|
24
|
+
}: DecryptButtonProps) {
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<style dangerouslySetInnerHTML={{ __html: STYLES }} />
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={decrypted ? undefined : onClick}
|
|
31
|
+
disabled={decrypted || loading}
|
|
32
|
+
className={`wf-decrypt-btn ${decrypted ? 'wf-decrypt-done' : 'wf-decrypt-idle'}`}
|
|
33
|
+
>
|
|
34
|
+
{loading ? (
|
|
35
|
+
<Spinner size={14} />
|
|
36
|
+
) : decrypted ? (
|
|
37
|
+
<svg
|
|
38
|
+
width={14}
|
|
39
|
+
height={14}
|
|
40
|
+
viewBox="0 0 24 24"
|
|
41
|
+
fill="none"
|
|
42
|
+
stroke="currentColor"
|
|
43
|
+
strokeWidth={2}
|
|
44
|
+
strokeLinecap="round"
|
|
45
|
+
strokeLinejoin="round"
|
|
46
|
+
>
|
|
47
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
48
|
+
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
|
49
|
+
</svg>
|
|
50
|
+
) : (
|
|
51
|
+
<svg
|
|
52
|
+
width={14}
|
|
53
|
+
height={14}
|
|
54
|
+
viewBox="0 0 24 24"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
strokeWidth={2}
|
|
58
|
+
strokeLinecap="round"
|
|
59
|
+
strokeLinejoin="round"
|
|
60
|
+
>
|
|
61
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
62
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
63
|
+
</svg>
|
|
64
|
+
)}
|
|
65
|
+
{loading ? 'Decrypting…' : decrypted ? 'Decrypted' : 'Decrypt'}
|
|
66
|
+
</button>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Copy } from 'lucide-react';
|
|
3
|
+
import { AlertCircle, Copy } from 'lucide-react';
|
|
4
4
|
import { toast } from 'sonner';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -19,10 +19,9 @@ export function isStructuredErrorWithStack(
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Renders an error with a `stack` field as
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* The entire block is copyable via a copy button.
|
|
22
|
+
* Renders an error with a `stack` field as a visually distinct error block.
|
|
23
|
+
* Shows the error message with an alert icon at the top, separated from
|
|
24
|
+
* the stack trace below.
|
|
26
25
|
*/
|
|
27
26
|
export function ErrorStackBlock({
|
|
28
27
|
value,
|
|
@@ -35,15 +34,22 @@ export function ErrorStackBlock({
|
|
|
35
34
|
|
|
36
35
|
return (
|
|
37
36
|
<div
|
|
38
|
-
className="relative overflow-
|
|
39
|
-
style={{
|
|
37
|
+
className="relative overflow-hidden rounded-md border"
|
|
38
|
+
style={{
|
|
39
|
+
borderColor: 'var(--ds-red-400)',
|
|
40
|
+
background: 'var(--ds-red-100)',
|
|
41
|
+
}}
|
|
40
42
|
>
|
|
41
43
|
<button
|
|
42
44
|
type="button"
|
|
43
45
|
aria-label="Copy error"
|
|
44
46
|
title="Copy"
|
|
45
|
-
className="!absolute !right-2 !top-2 !flex !h-6 !w-6 !items-center !justify-center !rounded-md !border
|
|
46
|
-
style={{
|
|
47
|
+
className="!absolute !right-2 !top-2 !flex !h-6 !w-6 !items-center !justify-center !rounded-md !border transition-transform transition-colors duration-100 hover:!bg-[var(--ds-red-200)] active:!scale-95"
|
|
48
|
+
style={{
|
|
49
|
+
borderColor: 'var(--ds-red-400)',
|
|
50
|
+
background: 'var(--ds-red-100)',
|
|
51
|
+
color: 'var(--ds-red-900)',
|
|
52
|
+
}}
|
|
47
53
|
onClick={() => {
|
|
48
54
|
navigator.clipboard
|
|
49
55
|
.writeText(copyText)
|
|
@@ -59,19 +65,23 @@ export function ErrorStackBlock({
|
|
|
59
65
|
</button>
|
|
60
66
|
|
|
61
67
|
{message && (
|
|
62
|
-
<
|
|
63
|
-
className="
|
|
68
|
+
<div
|
|
69
|
+
className="flex items-start gap-2 px-3 py-2.5 pr-10"
|
|
64
70
|
style={{
|
|
65
71
|
color: 'var(--ds-red-900)',
|
|
66
|
-
borderBottom: '1px solid var(--ds-
|
|
72
|
+
borderBottom: '1px solid var(--ds-red-400)',
|
|
67
73
|
}}
|
|
68
74
|
>
|
|
69
|
-
{
|
|
70
|
-
|
|
75
|
+
<AlertCircle className="h-4 w-4 shrink-0" style={{ marginTop: 1 }} />
|
|
76
|
+
<p className="text-xs font-semibold m-0 break-words">{message}</p>
|
|
77
|
+
</div>
|
|
71
78
|
)}
|
|
72
79
|
<pre
|
|
73
|
-
className="text-xs font-mono whitespace-pre-wrap break-words overflow-auto m-0"
|
|
74
|
-
style={{
|
|
80
|
+
className="px-3 py-2.5 text-xs font-mono whitespace-pre-wrap break-words overflow-auto m-0"
|
|
81
|
+
style={{
|
|
82
|
+
color: 'var(--ds-red-900)',
|
|
83
|
+
background: 'var(--ds-red-200)',
|
|
84
|
+
}}
|
|
75
85
|
>
|
|
76
86
|
{stack}
|
|
77
87
|
</pre>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Spinner } from './spinner';
|
|
4
|
+
|
|
5
|
+
const STYLES = `.wf-load-more{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:32px;padding:0 12px;border-radius:6px;font-size:13px;font-weight:500;line-height:20px;color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400);cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-load-more:hover{background:var(--ds-gray-alpha-200)}.wf-load-more:disabled{opacity:.6;cursor:default}.wf-load-more:disabled:hover{background:var(--ds-background-100)}`;
|
|
6
|
+
|
|
7
|
+
interface LoadMoreButtonProps {
|
|
8
|
+
loading?: boolean;
|
|
9
|
+
onClick?: () => void;
|
|
10
|
+
label?: string;
|
|
11
|
+
loadingLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A "Load more" button matching Geist's Button type="secondary" size="small"
|
|
16
|
+
* with a spinner prefix when loading.
|
|
17
|
+
*/
|
|
18
|
+
export function LoadMoreButton({
|
|
19
|
+
loading = false,
|
|
20
|
+
onClick,
|
|
21
|
+
label = 'Load more',
|
|
22
|
+
loadingLabel = 'Loading...',
|
|
23
|
+
}: LoadMoreButtonProps) {
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<style dangerouslySetInnerHTML={{ __html: STYLES }} />
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
onClick={onClick}
|
|
30
|
+
disabled={loading}
|
|
31
|
+
className="wf-load-more"
|
|
32
|
+
>
|
|
33
|
+
{loading && <Spinner size={14} />}
|
|
34
|
+
{loading ? loadingLabel : label}
|
|
35
|
+
</button>
|
|
36
|
+
</>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
const STYLES = `.wf-menu-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400);cursor:pointer;white-space:nowrap;transition:background 150ms}.wf-menu-btn:hover{background:var(--ds-gray-alpha-200)}.wf-menu-item{appearance:none;-webkit-appearance:none;border:none;display:flex;align-items:center;width:100%;height:40px;padding:0 8px;border-radius:6px;font-size:14px;color:var(--ds-gray-1000);background:transparent;cursor:pointer;transition:background 150ms}.wf-menu-item:hover{background:var(--ds-gray-alpha-100)}`;
|
|
6
|
+
|
|
7
|
+
export interface MenuDropdownOption<T extends string = string> {
|
|
8
|
+
value: T;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface MenuDropdownProps<T extends string = string> {
|
|
13
|
+
options: MenuDropdownOption<T>[];
|
|
14
|
+
value: T;
|
|
15
|
+
onChange: (value: T) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A dropdown menu that matches Geist's MenuButton (secondary) + Menu styling.
|
|
20
|
+
* Uses CSS classes with proper :hover specificity (no inline background).
|
|
21
|
+
*/
|
|
22
|
+
export function MenuDropdown<T extends string = string>({
|
|
23
|
+
options,
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
}: MenuDropdownProps<T>) {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
29
|
+
const label =
|
|
30
|
+
options.find((o) => o.value === value)?.label ?? options[0]?.label ?? '';
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!open) return;
|
|
34
|
+
function handleClickOutside(e: MouseEvent) {
|
|
35
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
36
|
+
setOpen(false);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
40
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
41
|
+
}, [open]);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
|
|
45
|
+
<style dangerouslySetInnerHTML={{ __html: STYLES }} />
|
|
46
|
+
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
className="wf-menu-btn"
|
|
50
|
+
onClick={() => setOpen(!open)}
|
|
51
|
+
>
|
|
52
|
+
<span>{label}</span>
|
|
53
|
+
<svg
|
|
54
|
+
width={16}
|
|
55
|
+
height={16}
|
|
56
|
+
viewBox="0 0 16 16"
|
|
57
|
+
fill="none"
|
|
58
|
+
style={{
|
|
59
|
+
marginLeft: 16,
|
|
60
|
+
marginRight: -4,
|
|
61
|
+
color: 'var(--ds-gray-900)',
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<path
|
|
65
|
+
d="M4.5 6L8 9.5L11.5 6"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth="1.5"
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
/>
|
|
71
|
+
</svg>
|
|
72
|
+
</button>
|
|
73
|
+
|
|
74
|
+
{open && (
|
|
75
|
+
<div
|
|
76
|
+
style={{
|
|
77
|
+
position: 'absolute',
|
|
78
|
+
right: 0,
|
|
79
|
+
top: '100%',
|
|
80
|
+
marginTop: 4,
|
|
81
|
+
minWidth: 140,
|
|
82
|
+
padding: 4,
|
|
83
|
+
borderRadius: 12,
|
|
84
|
+
background: 'var(--ds-background-100)',
|
|
85
|
+
boxShadow: 'var(--ds-shadow-menu, var(--ds-shadow-medium))',
|
|
86
|
+
zIndex: 2001,
|
|
87
|
+
}}
|
|
88
|
+
role="menu"
|
|
89
|
+
>
|
|
90
|
+
{options.map((option) => (
|
|
91
|
+
<button
|
|
92
|
+
key={option.value}
|
|
93
|
+
type="button"
|
|
94
|
+
role="menuitem"
|
|
95
|
+
className="wf-menu-item"
|
|
96
|
+
style={{
|
|
97
|
+
fontWeight: option.value === value ? 500 : 400,
|
|
98
|
+
}}
|
|
99
|
+
onClick={() => {
|
|
100
|
+
onChange(option.value);
|
|
101
|
+
setOpen(false);
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{option.label}
|
|
105
|
+
</button>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const KEYFRAMES = `@keyframes wf-spinner-fade{0%{opacity:1}100%{opacity:.15}}`;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spinner matching Geist's multi-line fade spinner.
|
|
5
|
+
* At size ≤12: 8 lines, ≤16: 10 lines, else: 12 lines.
|
|
6
|
+
*/
|
|
7
|
+
export function Spinner({
|
|
8
|
+
size = 14,
|
|
9
|
+
color,
|
|
10
|
+
}: {
|
|
11
|
+
size?: number;
|
|
12
|
+
color?: string;
|
|
13
|
+
}) {
|
|
14
|
+
const config =
|
|
15
|
+
size <= 12
|
|
16
|
+
? {
|
|
17
|
+
count: 8,
|
|
18
|
+
angle: 45,
|
|
19
|
+
delays: [-875, -750, -625, -500, -375, -250, -125, 0],
|
|
20
|
+
duration: 1000,
|
|
21
|
+
lineW: 3,
|
|
22
|
+
lineH: 1.5,
|
|
23
|
+
}
|
|
24
|
+
: size <= 16
|
|
25
|
+
? {
|
|
26
|
+
count: 10,
|
|
27
|
+
angle: 36,
|
|
28
|
+
delays: [-900, -800, -700, -600, -500, -400, -300, -200, -100, 0],
|
|
29
|
+
duration: 1000,
|
|
30
|
+
lineW: 4,
|
|
31
|
+
lineH: 1.5,
|
|
32
|
+
}
|
|
33
|
+
: {
|
|
34
|
+
count: 12,
|
|
35
|
+
angle: 30,
|
|
36
|
+
delays: [
|
|
37
|
+
-1100, -1000, -900, -800, -700, -600, -500, -400, -300, -200,
|
|
38
|
+
-100, 0,
|
|
39
|
+
],
|
|
40
|
+
duration: 1200,
|
|
41
|
+
lineW: size * 0.24,
|
|
42
|
+
lineH: size * 0.08,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
style={{
|
|
48
|
+
display: 'inline-flex',
|
|
49
|
+
position: 'relative',
|
|
50
|
+
width: size,
|
|
51
|
+
height: size,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<style dangerouslySetInnerHTML={{ __html: KEYFRAMES }} />
|
|
55
|
+
{config.delays.map((delay, i) => (
|
|
56
|
+
<span
|
|
57
|
+
key={delay}
|
|
58
|
+
style={{
|
|
59
|
+
position: 'absolute',
|
|
60
|
+
left: '50%',
|
|
61
|
+
top: '50%',
|
|
62
|
+
width: config.lineW,
|
|
63
|
+
height: config.lineH,
|
|
64
|
+
marginLeft: -config.lineW / 2,
|
|
65
|
+
marginTop: -config.lineH / 2,
|
|
66
|
+
borderRadius: 1,
|
|
67
|
+
backgroundColor: color ?? 'var(--ds-gray-700)',
|
|
68
|
+
transform: `rotate(${i * config.angle}deg) translate(${size * 0.36}px)`,
|
|
69
|
+
animation: `wf-spinner-fade ${config.duration}ms linear infinite`,
|
|
70
|
+
animationDelay: `${delay}ms`,
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</span>
|
|
75
|
+
);
|
|
76
|
+
}
|