@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.
@@ -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
+ }