@pylonsync/client 0.3.267
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 +125 -0
- package/package.json +32 -0
- package/src/components/AcceptInvite.tsx +160 -0
- package/src/components/ChatBot.tsx +228 -0
- package/src/components/ConnectAccount.tsx +119 -0
- package/src/components/EnsureGuest.tsx +49 -0
- package/src/components/EntityForm.tsx +308 -0
- package/src/components/EntityList.tsx +203 -0
- package/src/components/FileUpload.tsx +213 -0
- package/src/components/Gates.tsx +139 -0
- package/src/components/InviteMembers.tsx +562 -0
- package/src/components/OrganizationSwitcher.tsx +417 -0
- package/src/components/PasswordReset.tsx +302 -0
- package/src/components/SignIn.tsx +515 -0
- package/src/components/SignOutButton.tsx +42 -0
- package/src/components/UserButton.tsx +163 -0
- package/src/components/UserProfile.tsx +485 -0
- package/src/hooks/useAuth.ts +27 -0
- package/src/index.ts +130 -0
- package/src/lib/api.ts +368 -0
- package/src/lib/cn.ts +7 -0
- package/src/router/Router.tsx +282 -0
- package/src/router/context.ts +25 -0
- package/src/router/match.ts +106 -0
- package/src/router/useRouter.ts +40 -0
- package/src/theme.css +30 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
5
|
+
import { ensureGuestSession } from "../lib/api";
|
|
6
|
+
|
|
7
|
+
export interface EnsureGuestProps {
|
|
8
|
+
/**
|
|
9
|
+
* Render while the guest session is being minted. Defaults to
|
|
10
|
+
* nothing — the bootstrap is usually fast enough that a flash is
|
|
11
|
+
* worse than empty space. Pass a spinner / skeleton if you care.
|
|
12
|
+
*/
|
|
13
|
+
fallback?: ReactNode;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Drop-in wrapper that ensures the browser has a Pylon session before
|
|
19
|
+
* rendering children. If a token is already cached, renders
|
|
20
|
+
* immediately; otherwise calls `POST /api/auth/guest` and renders
|
|
21
|
+
* once the session token lands.
|
|
22
|
+
*
|
|
23
|
+
* Use this for zero-auth demos where every visitor implicitly becomes
|
|
24
|
+
* their own user — recordings, todos, scratch surfaces. Apps with
|
|
25
|
+
* real users should pair `<SignedOut><SignIn /></SignedOut>` with
|
|
26
|
+
* `<SignedIn>` instead.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <EnsureGuest>
|
|
30
|
+
* <TodoList />
|
|
31
|
+
* </EnsureGuest>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function EnsureGuest({ fallback = null, children }: EnsureGuestProps) {
|
|
35
|
+
const [ready, setReady] = useState(false);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
void ensureGuestSession().finally(() => {
|
|
40
|
+
if (!cancelled) setReady(true);
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
cancelled = true;
|
|
44
|
+
};
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
if (!ready) return <>{fallback}</>;
|
|
48
|
+
return <>{children}</>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type FormEvent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useEffect,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { db, type Row } from "@pylonsync/react";
|
|
11
|
+
import { cn } from "../lib/cn";
|
|
12
|
+
|
|
13
|
+
export interface EntityFormField {
|
|
14
|
+
/** Field key on the row. */
|
|
15
|
+
field: string;
|
|
16
|
+
/** Display label. Defaults to a humanized `field` name. */
|
|
17
|
+
label?: ReactNode;
|
|
18
|
+
/** Input type hint. Default: "text". */
|
|
19
|
+
type?:
|
|
20
|
+
| "text"
|
|
21
|
+
| "email"
|
|
22
|
+
| "url"
|
|
23
|
+
| "password"
|
|
24
|
+
| "number"
|
|
25
|
+
| "textarea"
|
|
26
|
+
| "checkbox"
|
|
27
|
+
| "select";
|
|
28
|
+
/** For `type: "select"`. */
|
|
29
|
+
options?: Array<{ value: string; label?: string }>;
|
|
30
|
+
/** Required flag. */
|
|
31
|
+
required?: boolean;
|
|
32
|
+
/** Placeholder. */
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
/** Helper text shown under the input. */
|
|
35
|
+
help?: ReactNode;
|
|
36
|
+
/** Default value when creating. */
|
|
37
|
+
defaultValue?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EntityFormProps<T extends Row = Row> {
|
|
41
|
+
/** Entity to write into. */
|
|
42
|
+
entity: string;
|
|
43
|
+
/** Field descriptors. */
|
|
44
|
+
fields: EntityFormField[];
|
|
45
|
+
/**
|
|
46
|
+
* If set, the form edits this existing row. Omit to create a new
|
|
47
|
+
* row (the default mode).
|
|
48
|
+
*/
|
|
49
|
+
row?: T;
|
|
50
|
+
/** Override the submit label. */
|
|
51
|
+
submitLabel?: ReactNode;
|
|
52
|
+
/** Optional cancel button — calls this handler when clicked. */
|
|
53
|
+
onCancel?: () => void;
|
|
54
|
+
/** Called after a successful create/update. */
|
|
55
|
+
onSubmitted?: (row: Row) => void;
|
|
56
|
+
/** Headline shown above the form. */
|
|
57
|
+
title?: ReactNode;
|
|
58
|
+
className?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Drop-in create / edit form for any entity. Uses `db.useEntity` for
|
|
63
|
+
* optimistic CRUD — the local replica gets the change immediately and
|
|
64
|
+
* the server reconciles via the sync push. No `onSubmit` boilerplate.
|
|
65
|
+
*
|
|
66
|
+
* ```tsx
|
|
67
|
+
* <EntityForm
|
|
68
|
+
* entity="Post"
|
|
69
|
+
* fields={[
|
|
70
|
+
* { field: "title", required: true },
|
|
71
|
+
* { field: "body", type: "textarea" },
|
|
72
|
+
* { field: "status", type: "select", options: [
|
|
73
|
+
* { value: "draft" }, { value: "published" },
|
|
74
|
+
* ]},
|
|
75
|
+
* ]}
|
|
76
|
+
* onSubmitted={(row) => router.push(`/posts/${row.id}`)}
|
|
77
|
+
* />
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function EntityForm<T extends Row = Row>({
|
|
81
|
+
entity,
|
|
82
|
+
fields,
|
|
83
|
+
row,
|
|
84
|
+
submitLabel,
|
|
85
|
+
onCancel,
|
|
86
|
+
onSubmitted,
|
|
87
|
+
title,
|
|
88
|
+
className,
|
|
89
|
+
}: EntityFormProps<T>) {
|
|
90
|
+
const mutation = db.useEntity(entity);
|
|
91
|
+
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
|
92
|
+
initialValues(fields, row),
|
|
93
|
+
);
|
|
94
|
+
const [error, setError] = useState<string | null>(null);
|
|
95
|
+
const [pending, setPending] = useState(false);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
setValues(initialValues(fields, row));
|
|
99
|
+
}, [fields, row]);
|
|
100
|
+
|
|
101
|
+
async function onSubmit(e: FormEvent) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
setError(null);
|
|
104
|
+
setPending(true);
|
|
105
|
+
try {
|
|
106
|
+
let result: Row;
|
|
107
|
+
if (row?.id) {
|
|
108
|
+
await mutation.update(String(row.id), values);
|
|
109
|
+
result = { ...row, ...values } as Row;
|
|
110
|
+
} else {
|
|
111
|
+
// `insert` returns the new row id; merge it back into the
|
|
112
|
+
// values so consumers see the same shape they'd get from
|
|
113
|
+
// a fresh fetch.
|
|
114
|
+
const id = await mutation.insert(values as Row);
|
|
115
|
+
result = { ...values, id } as Row;
|
|
116
|
+
}
|
|
117
|
+
onSubmitted?.(result);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
setError(err instanceof Error ? err.message : "Save failed.");
|
|
120
|
+
} finally {
|
|
121
|
+
setPending(false);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<form
|
|
127
|
+
onSubmit={onSubmit}
|
|
128
|
+
className={cn(
|
|
129
|
+
"mx-auto w-full max-w-md space-y-4 rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] p-6 shadow-sm",
|
|
130
|
+
className,
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
{title ? (
|
|
134
|
+
<h3 className="text-base font-semibold tracking-tight text-[var(--pylon-ink,#0a0a0a)]">
|
|
135
|
+
{title}
|
|
136
|
+
</h3>
|
|
137
|
+
) : null}
|
|
138
|
+
{fields.map((f) => (
|
|
139
|
+
<FieldRenderer
|
|
140
|
+
key={f.field}
|
|
141
|
+
field={f}
|
|
142
|
+
value={values[f.field]}
|
|
143
|
+
onChange={(v) =>
|
|
144
|
+
setValues((prev) => ({ ...prev, [f.field]: v }))
|
|
145
|
+
}
|
|
146
|
+
/>
|
|
147
|
+
))}
|
|
148
|
+
<div className="flex items-center gap-2">
|
|
149
|
+
<button
|
|
150
|
+
type="submit"
|
|
151
|
+
disabled={pending}
|
|
152
|
+
className="rounded-md bg-[var(--pylon-ink,#0a0a0a)] px-3 py-2 text-sm font-medium text-[var(--pylon-paper,#ffffff)] transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
153
|
+
>
|
|
154
|
+
{pending ? "…" : (submitLabel ?? (row ? "Save" : "Create"))}
|
|
155
|
+
</button>
|
|
156
|
+
{onCancel ? (
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={onCancel}
|
|
160
|
+
className="text-sm text-[var(--pylon-ink-2,#52525b)] hover:underline"
|
|
161
|
+
>
|
|
162
|
+
Cancel
|
|
163
|
+
</button>
|
|
164
|
+
) : null}
|
|
165
|
+
</div>
|
|
166
|
+
{error ? (
|
|
167
|
+
<p className="rounded-md border border-[var(--pylon-error-rule,#fecaca)] bg-[var(--pylon-error-bg,#fef2f2)] px-3 py-2 text-xs text-[var(--pylon-error-ink,#b91c1c)]">
|
|
168
|
+
{error}
|
|
169
|
+
</p>
|
|
170
|
+
) : null}
|
|
171
|
+
</form>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function FieldRenderer({
|
|
176
|
+
field,
|
|
177
|
+
value,
|
|
178
|
+
onChange,
|
|
179
|
+
}: {
|
|
180
|
+
field: EntityFormField;
|
|
181
|
+
value: unknown;
|
|
182
|
+
onChange: (v: unknown) => void;
|
|
183
|
+
}) {
|
|
184
|
+
const label = (
|
|
185
|
+
<span className="text-xs font-medium text-[var(--pylon-ink-2,#52525b)]">
|
|
186
|
+
{field.label ?? humanize(field.field)}
|
|
187
|
+
{field.required ? (
|
|
188
|
+
<span className="ml-1 text-[var(--pylon-error-ink,#b91c1c)]">*</span>
|
|
189
|
+
) : null}
|
|
190
|
+
</span>
|
|
191
|
+
);
|
|
192
|
+
const inputCls =
|
|
193
|
+
"w-full rounded-md border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] px-3 py-2 text-sm text-[var(--pylon-ink,#0a0a0a)] placeholder:text-[var(--pylon-ink-3,#a1a1aa)] focus:border-[var(--pylon-ink,#0a0a0a)] focus:outline-none";
|
|
194
|
+
|
|
195
|
+
const help = field.help ? (
|
|
196
|
+
<span className="text-[11px] text-[var(--pylon-ink-3,#71717a)]">
|
|
197
|
+
{field.help}
|
|
198
|
+
</span>
|
|
199
|
+
) : null;
|
|
200
|
+
|
|
201
|
+
if (field.type === "textarea") {
|
|
202
|
+
return (
|
|
203
|
+
<label className="block space-y-1.5">
|
|
204
|
+
{label}
|
|
205
|
+
<textarea
|
|
206
|
+
value={(value as string) ?? ""}
|
|
207
|
+
onChange={(e) => onChange(e.target.value)}
|
|
208
|
+
required={field.required}
|
|
209
|
+
placeholder={field.placeholder}
|
|
210
|
+
rows={4}
|
|
211
|
+
className={inputCls}
|
|
212
|
+
/>
|
|
213
|
+
{help}
|
|
214
|
+
</label>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (field.type === "checkbox") {
|
|
219
|
+
return (
|
|
220
|
+
<label className="flex items-start gap-2">
|
|
221
|
+
<input
|
|
222
|
+
type="checkbox"
|
|
223
|
+
checked={Boolean(value)}
|
|
224
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
225
|
+
className="mt-0.5 h-4 w-4 rounded border-[var(--pylon-rule,#e5e7eb)]"
|
|
226
|
+
/>
|
|
227
|
+
<span className="space-y-0.5">
|
|
228
|
+
{label}
|
|
229
|
+
{help}
|
|
230
|
+
</span>
|
|
231
|
+
</label>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (field.type === "select") {
|
|
236
|
+
return (
|
|
237
|
+
<label className="block space-y-1.5">
|
|
238
|
+
{label}
|
|
239
|
+
<select
|
|
240
|
+
value={(value as string) ?? ""}
|
|
241
|
+
onChange={(e) => onChange(e.target.value)}
|
|
242
|
+
required={field.required}
|
|
243
|
+
className={inputCls}
|
|
244
|
+
>
|
|
245
|
+
<option value="" disabled hidden>
|
|
246
|
+
Select…
|
|
247
|
+
</option>
|
|
248
|
+
{(field.options ?? []).map((opt) => (
|
|
249
|
+
<option key={opt.value} value={opt.value}>
|
|
250
|
+
{opt.label ?? opt.value}
|
|
251
|
+
</option>
|
|
252
|
+
))}
|
|
253
|
+
</select>
|
|
254
|
+
{help}
|
|
255
|
+
</label>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<label className="block space-y-1.5">
|
|
261
|
+
{label}
|
|
262
|
+
<input
|
|
263
|
+
type={field.type ?? "text"}
|
|
264
|
+
value={(value as string | number | undefined) ?? ""}
|
|
265
|
+
onChange={(e) =>
|
|
266
|
+
onChange(
|
|
267
|
+
field.type === "number"
|
|
268
|
+
? e.target.value === ""
|
|
269
|
+
? null
|
|
270
|
+
: Number(e.target.value)
|
|
271
|
+
: e.target.value,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
required={field.required}
|
|
275
|
+
placeholder={field.placeholder}
|
|
276
|
+
className={inputCls}
|
|
277
|
+
/>
|
|
278
|
+
{help}
|
|
279
|
+
</label>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function initialValues(
|
|
284
|
+
fields: EntityFormField[],
|
|
285
|
+
row: Row | undefined,
|
|
286
|
+
): Record<string, unknown> {
|
|
287
|
+
const out: Record<string, unknown> = {};
|
|
288
|
+
for (const f of fields) {
|
|
289
|
+
if (row && row[f.field] !== undefined) {
|
|
290
|
+
out[f.field] = row[f.field];
|
|
291
|
+
} else if (f.defaultValue !== undefined) {
|
|
292
|
+
out[f.field] = f.defaultValue;
|
|
293
|
+
} else if (f.type === "checkbox") {
|
|
294
|
+
out[f.field] = false;
|
|
295
|
+
} else {
|
|
296
|
+
out[f.field] = "";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function humanize(field: string): string {
|
|
303
|
+
return field
|
|
304
|
+
.replace(/([A-Z])/g, " $1")
|
|
305
|
+
.replace(/^./, (c) => c.toUpperCase())
|
|
306
|
+
.replace(/_/g, " ")
|
|
307
|
+
.trim();
|
|
308
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
|
|
4
|
+
import { type ReactNode, useMemo } from "react";
|
|
5
|
+
import { db, type Row } from "@pylonsync/react";
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
|
|
8
|
+
export interface EntityListColumn<T extends Row = Row> {
|
|
9
|
+
/** Field key on the row. */
|
|
10
|
+
field: keyof T & string;
|
|
11
|
+
/** Column header. Defaults to a humanized `field` name. */
|
|
12
|
+
label?: ReactNode;
|
|
13
|
+
/** Custom cell renderer. Default: stringified value. */
|
|
14
|
+
render?: (value: unknown, row: T) => ReactNode;
|
|
15
|
+
/** Tailwind / utility classes applied to the `<td>`. */
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EntityListProps<T extends Row = Row> {
|
|
20
|
+
/** Entity name (matches your schema declaration). */
|
|
21
|
+
entity: string;
|
|
22
|
+
/** Columns. When omitted, infers from the first row's keys. */
|
|
23
|
+
columns?: Array<EntityListColumn<T> | string>;
|
|
24
|
+
/** Live filter clause passed straight to `db.useQuery`. */
|
|
25
|
+
where?: Record<string, unknown>;
|
|
26
|
+
/** Server-side ordering. */
|
|
27
|
+
orderBy?: Record<string, "asc" | "desc">;
|
|
28
|
+
/** Max rows. */
|
|
29
|
+
limit?: number;
|
|
30
|
+
/** Click handler — gets the row. */
|
|
31
|
+
onRowClick?: (row: T) => void;
|
|
32
|
+
/** Render when there are zero rows. */
|
|
33
|
+
emptyState?: ReactNode;
|
|
34
|
+
/** Optional caption shown above the table (e.g., "Posts"). */
|
|
35
|
+
title?: ReactNode;
|
|
36
|
+
/** Render arbitrary actions in the top-right (e.g., a "+ New" button). */
|
|
37
|
+
actions?: ReactNode;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reactive table for any entity. Wraps `db.useQuery` so rows re-render
|
|
43
|
+
* live when the underlying data changes (insert / update / delete from
|
|
44
|
+
* any peer). Columns are explicit; inference is a fallback for "just
|
|
45
|
+
* give me something" demos.
|
|
46
|
+
*
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <EntityList
|
|
49
|
+
* entity="Post"
|
|
50
|
+
* columns={["title", "author", { field: "createdAt", render: relative }]}
|
|
51
|
+
* where={{ status: "published" }}
|
|
52
|
+
* onRowClick={(post) => router.push(`/posts/${post.id}`)}
|
|
53
|
+
* />
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function EntityList<T extends Row = Row>({
|
|
57
|
+
entity,
|
|
58
|
+
columns,
|
|
59
|
+
where,
|
|
60
|
+
orderBy,
|
|
61
|
+
limit,
|
|
62
|
+
onRowClick,
|
|
63
|
+
emptyState,
|
|
64
|
+
title,
|
|
65
|
+
actions,
|
|
66
|
+
className,
|
|
67
|
+
}: EntityListProps<T>) {
|
|
68
|
+
const { data, loading, error } = db.useQuery<T>(entity, {
|
|
69
|
+
where: where as Record<string, unknown> | undefined,
|
|
70
|
+
orderBy,
|
|
71
|
+
limit,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const resolvedColumns = useMemo(
|
|
75
|
+
() => resolveColumns<T>(columns, data),
|
|
76
|
+
[columns, data],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
className={cn(
|
|
82
|
+
"w-full overflow-hidden rounded-xl border border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] shadow-sm",
|
|
83
|
+
className,
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{title || actions ? (
|
|
87
|
+
<div className="flex items-center justify-between gap-3 border-b border-[var(--pylon-rule,#e5e7eb)] px-4 py-2.5">
|
|
88
|
+
<p className="text-sm font-semibold tracking-tight text-[var(--pylon-ink,#0a0a0a)]">
|
|
89
|
+
{title}
|
|
90
|
+
</p>
|
|
91
|
+
{actions ? <div className="flex gap-2">{actions}</div> : null}
|
|
92
|
+
</div>
|
|
93
|
+
) : null}
|
|
94
|
+
{error ? (
|
|
95
|
+
<p className="px-4 py-3 text-xs text-[var(--pylon-error-ink,#b91c1c)]">
|
|
96
|
+
{error.message}
|
|
97
|
+
</p>
|
|
98
|
+
) : null}
|
|
99
|
+
<div className="overflow-x-auto">
|
|
100
|
+
<table className="w-full border-collapse text-left text-sm">
|
|
101
|
+
<thead>
|
|
102
|
+
<tr className="border-b border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper-2,#f4f4f5)]">
|
|
103
|
+
{resolvedColumns.map((c) => (
|
|
104
|
+
<th
|
|
105
|
+
key={c.field}
|
|
106
|
+
className="px-4 py-2 text-[11px] font-medium uppercase tracking-wider text-[var(--pylon-ink-2,#52525b)]"
|
|
107
|
+
>
|
|
108
|
+
{c.label ?? humanize(c.field)}
|
|
109
|
+
</th>
|
|
110
|
+
))}
|
|
111
|
+
</tr>
|
|
112
|
+
</thead>
|
|
113
|
+
<tbody>
|
|
114
|
+
{loading && data.length === 0 ? (
|
|
115
|
+
<tr>
|
|
116
|
+
<td
|
|
117
|
+
colSpan={resolvedColumns.length || 1}
|
|
118
|
+
className="px-4 py-6 text-center text-xs text-[var(--pylon-ink-3,#71717a)]"
|
|
119
|
+
>
|
|
120
|
+
Loading…
|
|
121
|
+
</td>
|
|
122
|
+
</tr>
|
|
123
|
+
) : data.length === 0 ? (
|
|
124
|
+
<tr>
|
|
125
|
+
<td
|
|
126
|
+
colSpan={resolvedColumns.length || 1}
|
|
127
|
+
className="px-4 py-6 text-center text-xs text-[var(--pylon-ink-3,#71717a)]"
|
|
128
|
+
>
|
|
129
|
+
{emptyState ?? "No rows yet."}
|
|
130
|
+
</td>
|
|
131
|
+
</tr>
|
|
132
|
+
) : (
|
|
133
|
+
data.map((row, i) => (
|
|
134
|
+
<tr
|
|
135
|
+
key={(row as { id?: string }).id ?? i}
|
|
136
|
+
onClick={
|
|
137
|
+
onRowClick ? () => onRowClick(row) : undefined
|
|
138
|
+
}
|
|
139
|
+
className={cn(
|
|
140
|
+
"border-b border-[var(--pylon-rule,#e5e7eb)] last:border-b-0",
|
|
141
|
+
onRowClick &&
|
|
142
|
+
"cursor-pointer transition-colors hover:bg-[var(--pylon-paper-2,#f4f4f5)]",
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
{resolvedColumns.map((c) => (
|
|
146
|
+
<td
|
|
147
|
+
key={c.field}
|
|
148
|
+
className={cn(
|
|
149
|
+
"px-4 py-2 text-sm text-[var(--pylon-ink,#0a0a0a)]",
|
|
150
|
+
c.className,
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{c.render
|
|
154
|
+
? c.render((row as Row)[c.field], row)
|
|
155
|
+
: formatCell((row as Row)[c.field])}
|
|
156
|
+
</td>
|
|
157
|
+
))}
|
|
158
|
+
</tr>
|
|
159
|
+
))
|
|
160
|
+
)}
|
|
161
|
+
</tbody>
|
|
162
|
+
</table>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolveColumns<T extends Row>(
|
|
169
|
+
cols: Array<EntityListColumn<T> | string> | undefined,
|
|
170
|
+
rows: T[],
|
|
171
|
+
): EntityListColumn<T>[] {
|
|
172
|
+
if (cols && cols.length > 0) {
|
|
173
|
+
return cols.map((c) =>
|
|
174
|
+
typeof c === "string"
|
|
175
|
+
? ({ field: c as keyof T & string } satisfies EntityListColumn<T>)
|
|
176
|
+
: c,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const first = rows[0];
|
|
180
|
+
if (!first) return [];
|
|
181
|
+
return Object.keys(first)
|
|
182
|
+
.filter((k) => !k.startsWith("_"))
|
|
183
|
+
.slice(0, 6)
|
|
184
|
+
.map((k) => ({ field: k as keyof T & string }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function humanize(field: string): string {
|
|
188
|
+
return field
|
|
189
|
+
.replace(/([A-Z])/g, " $1")
|
|
190
|
+
.replace(/^./, (c) => c.toUpperCase())
|
|
191
|
+
.replace(/_/g, " ")
|
|
192
|
+
.trim();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function formatCell(value: unknown): ReactNode {
|
|
196
|
+
if (value === null || value === undefined) {
|
|
197
|
+
return <span className="text-[var(--pylon-ink-3,#a1a1aa)]">—</span>;
|
|
198
|
+
}
|
|
199
|
+
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
200
|
+
if (value instanceof Date) return value.toLocaleString();
|
|
201
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
202
|
+
return String(value);
|
|
203
|
+
}
|