@pablozaiden/webapp 0.0.1
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/LICENSE +21 -0
- package/README.md +26 -0
- package/docs/auth-validation.md +34 -0
- package/docs/auth.md +35 -0
- package/docs/deployment.md +49 -0
- package/docs/getting-started.md +62 -0
- package/docs/github-actions.md +421 -0
- package/docs/realtime.md +61 -0
- package/docs/release.md +64 -0
- package/docs/server.md +45 -0
- package/docs/settings.md +42 -0
- package/docs/sidebar.md +80 -0
- package/docs/ui-guidelines.md +55 -0
- package/package.json +60 -0
- package/src/build/build-binary.ts +42 -0
- package/src/build/index.ts +1 -0
- package/src/contracts/index.ts +86 -0
- package/src/server/auth/api-keys.ts +61 -0
- package/src/server/auth/crypto.ts +34 -0
- package/src/server/auth/device-auth.ts +324 -0
- package/src/server/auth/passkeys.ts +280 -0
- package/src/server/auth/request-origin.ts +33 -0
- package/src/server/auth/sqlite-store.ts +301 -0
- package/src/server/auth/store.ts +91 -0
- package/src/server/auth/types.ts +25 -0
- package/src/server/create-web-app-server.ts +447 -0
- package/src/server/index.ts +7 -0
- package/src/server/logger.ts +44 -0
- package/src/server/realtime/bus.ts +104 -0
- package/src/server/responses.ts +54 -0
- package/src/server/routes.ts +67 -0
- package/src/server/runtime-config.ts +101 -0
- package/src/server/same-origin.ts +35 -0
- package/src/types.d.ts +11 -0
- package/src/web/WebAppRoot.tsx +706 -0
- package/src/web/components/index.tsx +471 -0
- package/src/web/index.ts +5 -0
- package/src/web/realtime/useRealtime.ts +189 -0
- package/src/web/sidebar/types.ts +45 -0
- package/src/web/styles.css +1295 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type InputHTMLAttributes, type MouseEvent as ReactMouseEvent, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import type { ActionMenuItem, BadgeVariant } from "../sidebar/types";
|
|
4
|
+
|
|
5
|
+
export function Button({
|
|
6
|
+
variant = "default",
|
|
7
|
+
className = "",
|
|
8
|
+
...props
|
|
9
|
+
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "primary" | "danger" | "ghost" }) {
|
|
10
|
+
return <button {...props} className={`wapp-button wapp-button-${variant} ${className}`} />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function IconButton({
|
|
14
|
+
active = false,
|
|
15
|
+
className = "",
|
|
16
|
+
...props
|
|
17
|
+
}: ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
|
|
18
|
+
return <button {...props} className={`wapp-icon-button ${active ? "active" : ""} ${className}`} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Badge({ variant = "default", children }: { variant?: BadgeVariant; children: ReactNode }) {
|
|
22
|
+
return <span className={`wapp-badge wapp-badge-${variant}`}>{children}</span>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Panel({ title, description, actions, children, className = "" }: { title?: string; description?: string; actions?: ReactNode; children?: ReactNode; className?: string }) {
|
|
26
|
+
return (
|
|
27
|
+
<section className={`wapp-panel ${className}`}>
|
|
28
|
+
{title || description || actions ? (
|
|
29
|
+
<div className="wapp-panel-header">
|
|
30
|
+
<div>
|
|
31
|
+
{title ? <h2>{title}</h2> : null}
|
|
32
|
+
{description ? <p>{description}</p> : null}
|
|
33
|
+
</div>
|
|
34
|
+
{actions ? <div className="wapp-panel-actions">{actions}</div> : null}
|
|
35
|
+
</div>
|
|
36
|
+
) : null}
|
|
37
|
+
{children}
|
|
38
|
+
</section>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function EmptyState({ title, description, action }: { title: string; description?: string; action?: ReactNode }) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="wapp-empty-state">
|
|
45
|
+
<strong>{title}</strong>
|
|
46
|
+
{description ? <span>{description}</span> : null}
|
|
47
|
+
{action}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function LoadingState({ title = "Loading", description }: { title?: string; description?: string }) {
|
|
53
|
+
return (
|
|
54
|
+
<div className="wapp-loading-state" role="status">
|
|
55
|
+
<span className="wapp-spinner" aria-hidden="true" />
|
|
56
|
+
<strong>{title}</strong>
|
|
57
|
+
{description ? <span>{description}</span> : null}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ErrorState({ title = "Something went wrong", description, action }: { title?: string; description?: string; action?: ReactNode }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="wapp-error-state" role="alert">
|
|
65
|
+
<strong>{title}</strong>
|
|
66
|
+
{description ? <span>{description}</span> : null}
|
|
67
|
+
{action ? <div className="wapp-state-actions">{action}</div> : null}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function EntityHeader({
|
|
73
|
+
eyebrow,
|
|
74
|
+
title,
|
|
75
|
+
description,
|
|
76
|
+
actions,
|
|
77
|
+
}: {
|
|
78
|
+
eyebrow?: string;
|
|
79
|
+
title: ReactNode;
|
|
80
|
+
description?: ReactNode;
|
|
81
|
+
actions?: ReactNode;
|
|
82
|
+
}) {
|
|
83
|
+
return (
|
|
84
|
+
<div className="wapp-entity-header">
|
|
85
|
+
<div>
|
|
86
|
+
{eyebrow ? <span className="wapp-eyebrow">{eyebrow}</span> : null}
|
|
87
|
+
<h2>{title}</h2>
|
|
88
|
+
{description ? <p>{description}</p> : null}
|
|
89
|
+
</div>
|
|
90
|
+
{actions ? <div className="wapp-entity-header-actions">{actions}</div> : null}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function DataList({ children, empty }: { children?: ReactNode; empty?: ReactNode }) {
|
|
96
|
+
return <div className="wapp-data-list">{children ?? empty ?? null}</div>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function DataListRow({
|
|
100
|
+
title,
|
|
101
|
+
description,
|
|
102
|
+
meta,
|
|
103
|
+
badge,
|
|
104
|
+
actions,
|
|
105
|
+
onClick,
|
|
106
|
+
}: {
|
|
107
|
+
title: ReactNode;
|
|
108
|
+
description?: ReactNode;
|
|
109
|
+
meta?: ReactNode;
|
|
110
|
+
badge?: ReactNode;
|
|
111
|
+
actions?: ReactNode;
|
|
112
|
+
onClick?: () => void;
|
|
113
|
+
}) {
|
|
114
|
+
const content = (
|
|
115
|
+
<>
|
|
116
|
+
<span className="wapp-data-list-row-main">
|
|
117
|
+
<strong>{title}</strong>
|
|
118
|
+
{description ? <small>{description}</small> : null}
|
|
119
|
+
</span>
|
|
120
|
+
{meta ? <span className="wapp-data-list-row-meta">{meta}</span> : null}
|
|
121
|
+
{badge ? <span className="wapp-data-list-row-badge">{badge}</span> : null}
|
|
122
|
+
{actions ? <span className="wapp-data-list-row-actions">{actions}</span> : null}
|
|
123
|
+
</>
|
|
124
|
+
);
|
|
125
|
+
return onClick ? (
|
|
126
|
+
<button type="button" className="wapp-data-list-row interactive" onClick={onClick}>{content}</button>
|
|
127
|
+
) : (
|
|
128
|
+
<div className="wapp-data-list-row">{content}</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function TextField({ label, hint, error, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: string; hint?: string; error?: string }) {
|
|
133
|
+
return (
|
|
134
|
+
<label className="wapp-field">
|
|
135
|
+
<span>{label}</span>
|
|
136
|
+
<input {...props} />
|
|
137
|
+
{hint ? <small>{hint}</small> : null}
|
|
138
|
+
{error ? <small className="error">{error}</small> : null}
|
|
139
|
+
</label>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function TextAreaField({ label, hint, error, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string; hint?: string; error?: string }) {
|
|
144
|
+
return (
|
|
145
|
+
<label className="wapp-field">
|
|
146
|
+
<span>{label}</span>
|
|
147
|
+
<textarea {...props} />
|
|
148
|
+
{hint ? <small>{hint}</small> : null}
|
|
149
|
+
{error ? <small className="error">{error}</small> : null}
|
|
150
|
+
</label>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function SelectField({ label, children, ...props }: SelectHTMLAttributes<HTMLSelectElement> & { label: string; children: ReactNode }) {
|
|
155
|
+
return (
|
|
156
|
+
<label className="wapp-field">
|
|
157
|
+
<span>{label}</span>
|
|
158
|
+
<select {...props}>{children}</select>
|
|
159
|
+
</label>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function SegmentedControl<T extends string>({ value, options, onChange, label }: { value: T; options: Array<{ value: T; label: string }>; onChange: (value: T) => void; label: string }) {
|
|
164
|
+
return (
|
|
165
|
+
<div className="wapp-segmented" role="group" aria-label={label}>
|
|
166
|
+
{options.map((option) => (
|
|
167
|
+
<button type="button" className={value === option.value ? "active" : ""} key={option.value} onClick={() => onChange(option.value)}>
|
|
168
|
+
{option.label}
|
|
169
|
+
</button>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function FormSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
|
|
176
|
+
return (
|
|
177
|
+
<section className="wapp-form-section">
|
|
178
|
+
<div>
|
|
179
|
+
<h3>{title}</h3>
|
|
180
|
+
{description ? <p>{description}</p> : null}
|
|
181
|
+
</div>
|
|
182
|
+
<div className="wapp-form-section-body">{children}</div>
|
|
183
|
+
</section>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function FormGroup({ title, description, children, actions }: { title?: string; description?: string; children: ReactNode; actions?: ReactNode }) {
|
|
188
|
+
return (
|
|
189
|
+
<div className="wapp-form-group">
|
|
190
|
+
{title || description || actions ? (
|
|
191
|
+
<div className="wapp-form-group-header">
|
|
192
|
+
<div>
|
|
193
|
+
{title ? <strong>{title}</strong> : null}
|
|
194
|
+
{description ? <p>{description}</p> : null}
|
|
195
|
+
</div>
|
|
196
|
+
{actions ? <div className="wapp-form-group-actions">{actions}</div> : null}
|
|
197
|
+
</div>
|
|
198
|
+
) : null}
|
|
199
|
+
<div className="wapp-form-group-body">{children}</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function FormActions({ children }: { children: ReactNode }) {
|
|
205
|
+
return <div className="wapp-form-actions">{children}</div>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function DangerZone({ title, description, actions }: { title: string; description?: string; actions: ReactNode }) {
|
|
209
|
+
return (
|
|
210
|
+
<section className="wapp-danger-zone">
|
|
211
|
+
<div>
|
|
212
|
+
<strong>{title}</strong>
|
|
213
|
+
{description ? <p>{description}</p> : null}
|
|
214
|
+
</div>
|
|
215
|
+
<div className="wapp-danger-zone-actions">{actions}</div>
|
|
216
|
+
</section>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function CodeValue({ value, label, copyLabel = "Copy" }: { value: string; label?: string; copyLabel?: string }) {
|
|
221
|
+
const [copied, setCopied] = useState(false);
|
|
222
|
+
async function copy() {
|
|
223
|
+
await navigator.clipboard.writeText(value);
|
|
224
|
+
setCopied(true);
|
|
225
|
+
window.setTimeout(() => setCopied(false), 1400);
|
|
226
|
+
}
|
|
227
|
+
return (
|
|
228
|
+
<div className="wapp-code-value">
|
|
229
|
+
{label ? <span>{label}</span> : null}
|
|
230
|
+
<code>{value}</code>
|
|
231
|
+
<Button type="button" onClick={() => void copy()}>{copied ? "Copied" : copyLabel}</Button>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function ConfirmDialog({
|
|
237
|
+
open,
|
|
238
|
+
title,
|
|
239
|
+
message,
|
|
240
|
+
confirmLabel = "Confirm",
|
|
241
|
+
danger = false,
|
|
242
|
+
onCancel,
|
|
243
|
+
onConfirm,
|
|
244
|
+
}: {
|
|
245
|
+
open: boolean;
|
|
246
|
+
title: string;
|
|
247
|
+
message: string;
|
|
248
|
+
confirmLabel?: string;
|
|
249
|
+
danger?: boolean;
|
|
250
|
+
onCancel: () => void;
|
|
251
|
+
onConfirm: () => void;
|
|
252
|
+
}) {
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!open) return;
|
|
255
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
256
|
+
if (event.key === "Escape") {
|
|
257
|
+
onCancel();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
261
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
262
|
+
}, [onCancel, open]);
|
|
263
|
+
|
|
264
|
+
if (!open) return null;
|
|
265
|
+
return createPortal(
|
|
266
|
+
<div className="wapp-dialog-backdrop" role="presentation">
|
|
267
|
+
<div className="wapp-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
|
268
|
+
<div className="wapp-dialog-title">
|
|
269
|
+
<h2>{title}</h2>
|
|
270
|
+
<button type="button" className="wapp-dialog-close" aria-label="Close dialog" onClick={onCancel}>×</button>
|
|
271
|
+
</div>
|
|
272
|
+
<div className="wapp-dialog-body">
|
|
273
|
+
<p>{message}</p>
|
|
274
|
+
</div>
|
|
275
|
+
<div className="wapp-dialog-actions">
|
|
276
|
+
<Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button>
|
|
277
|
+
<Button type="button" variant={danger ? "danger" : "primary"} onClick={onConfirm}>{confirmLabel}</Button>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</div>,
|
|
281
|
+
document.body,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function Toolbar({ children }: { children: ReactNode }) {
|
|
286
|
+
return <div className="wapp-toolbar">{children}</div>;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface ContextMenuPosition {
|
|
290
|
+
x: number;
|
|
291
|
+
y: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function MenuIcon() {
|
|
295
|
+
return (
|
|
296
|
+
<svg aria-hidden="true" viewBox="0 0 24 24" className="wapp-svg">
|
|
297
|
+
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
298
|
+
</svg>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function boundedMenuPosition(menu: HTMLDivElement | null, position: ContextMenuPosition): ContextMenuPosition {
|
|
303
|
+
if (!menu) return position;
|
|
304
|
+
const margin = 8;
|
|
305
|
+
const rect = menu.getBoundingClientRect();
|
|
306
|
+
return {
|
|
307
|
+
x: Math.max(margin, Math.min(position.x, window.innerWidth - rect.width - margin)),
|
|
308
|
+
y: Math.max(margin, Math.min(position.y, window.innerHeight - rect.height - margin)),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function ActionMenuItems({ items, onItemClick }: { items: ActionMenuItem[]; onItemClick: (item: ActionMenuItem) => void }) {
|
|
313
|
+
return (
|
|
314
|
+
<div className="wapp-action-menu-items">
|
|
315
|
+
{items.map((item, index) => (
|
|
316
|
+
<button
|
|
317
|
+
type="button"
|
|
318
|
+
role="menuitem"
|
|
319
|
+
key={item.id ?? `${item.label}:${index}`}
|
|
320
|
+
disabled={item.disabled}
|
|
321
|
+
className={`wapp-action-menu-item ${item.destructive ? "danger" : ""}`}
|
|
322
|
+
onClick={() => onItemClick(item)}
|
|
323
|
+
>
|
|
324
|
+
{item.label}
|
|
325
|
+
</button>
|
|
326
|
+
))}
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function ActionMenu({
|
|
332
|
+
items,
|
|
333
|
+
ariaLabel = "Actions",
|
|
334
|
+
disabled = false,
|
|
335
|
+
trigger,
|
|
336
|
+
}: {
|
|
337
|
+
items: ActionMenuItem[];
|
|
338
|
+
ariaLabel?: string;
|
|
339
|
+
disabled?: boolean;
|
|
340
|
+
trigger?: ReactNode;
|
|
341
|
+
}) {
|
|
342
|
+
const [open, setOpen] = useState(false);
|
|
343
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
344
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
345
|
+
const [style, setStyle] = useState<CSSProperties>({ position: "fixed", top: -9999, left: -9999 });
|
|
346
|
+
const close = useCallback(() => setOpen(false), []);
|
|
347
|
+
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
if (!open) return;
|
|
350
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
351
|
+
if (event.key === "Escape") close();
|
|
352
|
+
}
|
|
353
|
+
function handleMouseDown(event: MouseEvent) {
|
|
354
|
+
if (!menuRef.current?.contains(event.target as Node) && !triggerRef.current?.contains(event.target as Node)) {
|
|
355
|
+
close();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
359
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
360
|
+
return () => {
|
|
361
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
362
|
+
document.removeEventListener("mousedown", handleMouseDown);
|
|
363
|
+
};
|
|
364
|
+
}, [close, open]);
|
|
365
|
+
|
|
366
|
+
useLayoutEffect(() => {
|
|
367
|
+
if (!open || !triggerRef.current || !menuRef.current) return;
|
|
368
|
+
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
369
|
+
const menuRect = menuRef.current.getBoundingClientRect();
|
|
370
|
+
const margin = 8;
|
|
371
|
+
setStyle({
|
|
372
|
+
position: "fixed",
|
|
373
|
+
top: Math.max(margin, Math.min(triggerRect.bottom + 4, window.innerHeight - menuRect.height - margin)),
|
|
374
|
+
left: Math.max(margin, Math.min(triggerRect.right - menuRect.width, window.innerWidth - menuRect.width - margin)),
|
|
375
|
+
});
|
|
376
|
+
}, [open]);
|
|
377
|
+
|
|
378
|
+
function handleItemClick(item: ActionMenuItem) {
|
|
379
|
+
if (item.disabled) return;
|
|
380
|
+
close();
|
|
381
|
+
item.onAction();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<>
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
ref={triggerRef}
|
|
389
|
+
className="wapp-action-menu-trigger"
|
|
390
|
+
aria-label={ariaLabel}
|
|
391
|
+
aria-haspopup="menu"
|
|
392
|
+
aria-expanded={open}
|
|
393
|
+
disabled={disabled || items.length === 0}
|
|
394
|
+
onClick={() => setOpen((current) => !current)}
|
|
395
|
+
>
|
|
396
|
+
{trigger ?? <MenuIcon />}
|
|
397
|
+
</button>
|
|
398
|
+
{open ? createPortal(
|
|
399
|
+
<div ref={menuRef} className="wapp-action-menu" role="menu" aria-label={ariaLabel} style={style}>
|
|
400
|
+
<ActionMenuItems items={items} onItemClick={handleItemClick} />
|
|
401
|
+
</div>,
|
|
402
|
+
document.body,
|
|
403
|
+
) : null}
|
|
404
|
+
</>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function ContextMenu({
|
|
409
|
+
items,
|
|
410
|
+
position,
|
|
411
|
+
onClose,
|
|
412
|
+
ariaLabel = "Context menu",
|
|
413
|
+
}: {
|
|
414
|
+
items: ActionMenuItem[];
|
|
415
|
+
position: ContextMenuPosition | null;
|
|
416
|
+
onClose: () => void;
|
|
417
|
+
ariaLabel?: string;
|
|
418
|
+
}) {
|
|
419
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
420
|
+
const [resolvedPosition, setResolvedPosition] = useState<ContextMenuPosition | null>(position);
|
|
421
|
+
|
|
422
|
+
useLayoutEffect(() => {
|
|
423
|
+
setResolvedPosition(position ? boundedMenuPosition(menuRef.current, position) : null);
|
|
424
|
+
}, [position]);
|
|
425
|
+
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
if (!position) return;
|
|
428
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
429
|
+
if (event.key === "Escape") onClose();
|
|
430
|
+
}
|
|
431
|
+
function handleMouseDown(event: MouseEvent) {
|
|
432
|
+
if (!menuRef.current?.contains(event.target as Node)) onClose();
|
|
433
|
+
}
|
|
434
|
+
function handleScroll() {
|
|
435
|
+
onClose();
|
|
436
|
+
}
|
|
437
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
438
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
439
|
+
window.addEventListener("scroll", handleScroll, true);
|
|
440
|
+
return () => {
|
|
441
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
442
|
+
document.removeEventListener("mousedown", handleMouseDown);
|
|
443
|
+
window.removeEventListener("scroll", handleScroll, true);
|
|
444
|
+
};
|
|
445
|
+
}, [onClose, position]);
|
|
446
|
+
|
|
447
|
+
if (!position || !resolvedPosition) return null;
|
|
448
|
+
|
|
449
|
+
function handleItemClick(item: ActionMenuItem) {
|
|
450
|
+
if (item.disabled) return;
|
|
451
|
+
onClose();
|
|
452
|
+
item.onAction();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return createPortal(
|
|
456
|
+
<div
|
|
457
|
+
ref={menuRef}
|
|
458
|
+
className="wapp-action-menu"
|
|
459
|
+
role="menu"
|
|
460
|
+
aria-label={ariaLabel}
|
|
461
|
+
style={{ position: "fixed", left: resolvedPosition.x, top: resolvedPosition.y }}
|
|
462
|
+
onContextMenu={(event: ReactMouseEvent<HTMLDivElement>) => {
|
|
463
|
+
event.preventDefault();
|
|
464
|
+
event.stopPropagation();
|
|
465
|
+
}}
|
|
466
|
+
>
|
|
467
|
+
<ActionMenuItems items={items} onItemClick={handleItemClick} />
|
|
468
|
+
</div>,
|
|
469
|
+
document.body,
|
|
470
|
+
);
|
|
471
|
+
}
|
package/src/web/index.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export type RealtimeStatus = "connecting" | "open" | "closed";
|
|
4
|
+
export type RealtimeAction = "created" | "updated" | "changed" | "deleted";
|
|
5
|
+
|
|
6
|
+
export interface ResourceRealtimeEvent<TPayload = unknown> {
|
|
7
|
+
type: `${string}.${RealtimeAction}`;
|
|
8
|
+
resource: string;
|
|
9
|
+
action: RealtimeAction;
|
|
10
|
+
id?: string;
|
|
11
|
+
scope?: string;
|
|
12
|
+
payload?: TPayload;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RealtimeEventSelector<TEvent = ResourceRealtimeEvent> {
|
|
16
|
+
resources?: string[];
|
|
17
|
+
actions?: RealtimeAction[];
|
|
18
|
+
ids?: string[];
|
|
19
|
+
scopes?: string[];
|
|
20
|
+
types?: string[];
|
|
21
|
+
predicate?: (event: TEvent) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function eventField(event: unknown, key: "type" | "resource" | "action" | "id" | "scope"): string | undefined {
|
|
25
|
+
return typeof event === "object" && event !== null && key in event ? String((event as Record<string, unknown>)[key] ?? "") || undefined : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function realtimeEventMatches<TEvent>(event: TEvent, selector?: RealtimeEventSelector<TEvent>): boolean {
|
|
29
|
+
if (!selector) return true;
|
|
30
|
+
const type = eventField(event, "type");
|
|
31
|
+
const resource = eventField(event, "resource");
|
|
32
|
+
const action = eventField(event, "action");
|
|
33
|
+
const id = eventField(event, "id");
|
|
34
|
+
const scope = eventField(event, "scope");
|
|
35
|
+
if (selector.types?.length && (!type || !selector.types.includes(type))) return false;
|
|
36
|
+
if (selector.resources?.length && (!resource || !selector.resources.includes(resource))) return false;
|
|
37
|
+
if (selector.actions?.length && (!action || !selector.actions.includes(action as RealtimeAction))) return false;
|
|
38
|
+
if (selector.ids?.length && (!id || !selector.ids.includes(id))) return false;
|
|
39
|
+
if (selector.scopes?.length && (!scope || !selector.scopes.includes(scope))) return false;
|
|
40
|
+
return selector.predicate?.(event) ?? true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useRealtime<TEvent>({
|
|
44
|
+
enabled = true,
|
|
45
|
+
path = "/api/ws",
|
|
46
|
+
filters,
|
|
47
|
+
onEvent,
|
|
48
|
+
}: {
|
|
49
|
+
enabled?: boolean;
|
|
50
|
+
path?: string;
|
|
51
|
+
filters?: Record<string, string | undefined>;
|
|
52
|
+
onEvent?: (event: TEvent) => void;
|
|
53
|
+
}) {
|
|
54
|
+
const [status, setStatus] = useState<RealtimeStatus>("connecting");
|
|
55
|
+
const [lastEvent, setLastEvent] = useState<TEvent>();
|
|
56
|
+
const retryRef = useRef(0);
|
|
57
|
+
const onEventRef = useRef(onEvent);
|
|
58
|
+
onEventRef.current = onEvent;
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!enabled) {
|
|
62
|
+
setStatus("closed");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let closed = false;
|
|
66
|
+
let socket: WebSocket | undefined;
|
|
67
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
+
|
|
69
|
+
function connect() {
|
|
70
|
+
const params = new URLSearchParams();
|
|
71
|
+
for (const [key, value] of Object.entries(filters ?? {})) {
|
|
72
|
+
if (value) params.set(key, value);
|
|
73
|
+
}
|
|
74
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
75
|
+
socket = new WebSocket(`${protocol}//${window.location.host}${path}${params.size ? `?${params.toString()}` : ""}`);
|
|
76
|
+
setStatus("connecting");
|
|
77
|
+
socket.onopen = () => {
|
|
78
|
+
retryRef.current = 0;
|
|
79
|
+
setStatus("open");
|
|
80
|
+
};
|
|
81
|
+
socket.onmessage = (message) => {
|
|
82
|
+
try {
|
|
83
|
+
const data = JSON.parse(String(message.data)) as { type: string; event?: TEvent };
|
|
84
|
+
if (data.type === "event" && data.event) {
|
|
85
|
+
setLastEvent(data.event);
|
|
86
|
+
onEventRef.current?.(data.event);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore malformed realtime payloads from old clients/servers.
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
socket.onclose = () => {
|
|
93
|
+
setStatus("closed");
|
|
94
|
+
if (!closed) {
|
|
95
|
+
const delay = Math.min(1000 * 2 ** retryRef.current, 10000);
|
|
96
|
+
retryRef.current += 1;
|
|
97
|
+
timeout = setTimeout(connect, delay);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
connect();
|
|
103
|
+
return () => {
|
|
104
|
+
closed = true;
|
|
105
|
+
if (timeout) clearTimeout(timeout);
|
|
106
|
+
socket?.close();
|
|
107
|
+
};
|
|
108
|
+
}, [enabled, JSON.stringify(filters), path]);
|
|
109
|
+
|
|
110
|
+
return { status, lastEvent };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function useRealtimeRefresh<TEvent = ResourceRealtimeEvent>({
|
|
114
|
+
refresh,
|
|
115
|
+
enabled = true,
|
|
116
|
+
path,
|
|
117
|
+
filters,
|
|
118
|
+
resources,
|
|
119
|
+
actions,
|
|
120
|
+
ids,
|
|
121
|
+
scopes,
|
|
122
|
+
types,
|
|
123
|
+
predicate,
|
|
124
|
+
onEvent,
|
|
125
|
+
}: {
|
|
126
|
+
refresh: (event: TEvent) => void | Promise<void>;
|
|
127
|
+
enabled?: boolean;
|
|
128
|
+
path?: string;
|
|
129
|
+
filters?: Record<string, string | undefined>;
|
|
130
|
+
onEvent?: (event: TEvent) => void;
|
|
131
|
+
} & RealtimeEventSelector<TEvent>) {
|
|
132
|
+
const refreshRef = useRef(refresh);
|
|
133
|
+
const onEventRef = useRef(onEvent);
|
|
134
|
+
refreshRef.current = refresh;
|
|
135
|
+
onEventRef.current = onEvent;
|
|
136
|
+
const selector = { resources, actions, ids, scopes, types, predicate } satisfies RealtimeEventSelector<TEvent>;
|
|
137
|
+
|
|
138
|
+
return useRealtime<TEvent>({
|
|
139
|
+
enabled,
|
|
140
|
+
path,
|
|
141
|
+
filters,
|
|
142
|
+
onEvent: (event) => {
|
|
143
|
+
if (!realtimeEventMatches(event, selector)) return;
|
|
144
|
+
onEventRef.current?.(event);
|
|
145
|
+
void refreshRef.current(event);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function useLiveQuery<TData, TEvent = ResourceRealtimeEvent>({
|
|
151
|
+
load,
|
|
152
|
+
initialData,
|
|
153
|
+
deps = [],
|
|
154
|
+
realtime,
|
|
155
|
+
}: {
|
|
156
|
+
load: () => Promise<TData>;
|
|
157
|
+
initialData?: TData;
|
|
158
|
+
deps?: readonly unknown[];
|
|
159
|
+
realtime?: false | Omit<Parameters<typeof useRealtimeRefresh<TEvent>>[0], "refresh">;
|
|
160
|
+
}) {
|
|
161
|
+
const [data, setData] = useState<TData | undefined>(initialData);
|
|
162
|
+
const [error, setError] = useState<Error>();
|
|
163
|
+
const [loading, setLoading] = useState(initialData === undefined);
|
|
164
|
+
const depsKey = JSON.stringify(deps);
|
|
165
|
+
|
|
166
|
+
const refresh = useCallback(async () => {
|
|
167
|
+
setLoading(true);
|
|
168
|
+
try {
|
|
169
|
+
setData(await load());
|
|
170
|
+
setError(undefined);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
173
|
+
} finally {
|
|
174
|
+
setLoading(false);
|
|
175
|
+
}
|
|
176
|
+
}, [load, depsKey]);
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
void refresh();
|
|
180
|
+
}, [refresh]);
|
|
181
|
+
|
|
182
|
+
const realtimeState = useRealtimeRefresh<TEvent>({
|
|
183
|
+
...(realtime === false ? {} : realtime),
|
|
184
|
+
enabled: realtime !== false,
|
|
185
|
+
refresh: () => void refresh(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return { data, error, loading, refresh, realtimeStatus: realtimeState.status, lastEvent: realtimeState.lastEvent };
|
|
189
|
+
}
|