@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,213 @@
1
+ "use client";
2
+ import React from "react";
3
+
4
+ import {
5
+ type ChangeEvent,
6
+ type DragEvent,
7
+ type ReactNode,
8
+ useCallback,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { uploadFile, type UploadedFile } from "@pylonsync/react";
13
+ import { cn } from "../lib/cn";
14
+
15
+ export interface FileUploadProps {
16
+ /** Called once for each successfully uploaded file. */
17
+ onUploaded?: (file: UploadedFile, source: File) => void;
18
+ /** Called once with the full batch after all files finish (errors and all). */
19
+ onComplete?: (results: FileUploadResult[]) => void;
20
+ /** MIME / extension hint passed to `<input accept=...>`. */
21
+ accept?: string;
22
+ /** Allow multi-file selection / drop. Default: false. */
23
+ multiple?: boolean;
24
+ /** Max file size in bytes — files exceeding this are rejected client-side. */
25
+ maxSizeBytes?: number;
26
+ /** Override the headline. */
27
+ label?: ReactNode;
28
+ /** Override the helper text. */
29
+ helperText?: ReactNode;
30
+ className?: string;
31
+ }
32
+
33
+ export interface FileUploadResult {
34
+ file: File;
35
+ status: "ok" | "error";
36
+ uploaded?: UploadedFile;
37
+ error?: string;
38
+ }
39
+
40
+ /**
41
+ * Drop-in drag-and-drop file picker. Streams each file to
42
+ * `/api/files/upload` via the existing `uploadFile` helper, surfaces
43
+ * per-file progress + errors, and emits `onUploaded` as each file
44
+ * lands.
45
+ *
46
+ * Pure UI shell — no provider configuration of its own. Pylon's file
47
+ * router handles where bytes actually go (local disk for self-host,
48
+ * Stack0 / S3 for cloud).
49
+ */
50
+ export function FileUpload({
51
+ onUploaded,
52
+ onComplete,
53
+ accept,
54
+ multiple,
55
+ maxSizeBytes,
56
+ label = "Drop files to upload",
57
+ helperText = "or click to browse",
58
+ className,
59
+ }: FileUploadProps) {
60
+ const [dragging, setDragging] = useState(false);
61
+ const [items, setItems] = useState<FileUploadResult[]>([]);
62
+ const inputRef = useRef<HTMLInputElement | null>(null);
63
+
64
+ const startUpload = useCallback(
65
+ async (files: FileList | File[]) => {
66
+ const arr = Array.from(files);
67
+ if (arr.length === 0) return;
68
+ const initial: FileUploadResult[] = arr.map((file) => {
69
+ if (maxSizeBytes && file.size > maxSizeBytes) {
70
+ return {
71
+ file,
72
+ status: "error",
73
+ error: `Larger than ${humanSize(maxSizeBytes)}`,
74
+ };
75
+ }
76
+ return { file, status: "ok" };
77
+ });
78
+ setItems((prev) => [...prev, ...initial]);
79
+
80
+ const settled = await Promise.all(
81
+ initial.map(async (entry) => {
82
+ if (entry.status === "error") return entry;
83
+ try {
84
+ const uploaded = await uploadFile(entry.file);
85
+ onUploaded?.(uploaded, entry.file);
86
+ return { ...entry, uploaded } satisfies FileUploadResult;
87
+ } catch (err) {
88
+ return {
89
+ ...entry,
90
+ status: "error" as const,
91
+ error: err instanceof Error ? err.message : "Upload failed",
92
+ };
93
+ }
94
+ }),
95
+ );
96
+ setItems((prev) => {
97
+ const next = prev.slice();
98
+ for (const result of settled) {
99
+ const idx = next.findIndex(
100
+ (r) => r.file === result.file && r !== result,
101
+ );
102
+ if (idx >= 0) next[idx] = result;
103
+ }
104
+ return next;
105
+ });
106
+ onComplete?.(settled);
107
+ },
108
+ [maxSizeBytes, onComplete, onUploaded],
109
+ );
110
+
111
+ function onDrop(e: DragEvent<HTMLDivElement>) {
112
+ e.preventDefault();
113
+ setDragging(false);
114
+ if (e.dataTransfer?.files) void startUpload(e.dataTransfer.files);
115
+ }
116
+
117
+ function onChange(e: ChangeEvent<HTMLInputElement>) {
118
+ if (e.target.files) void startUpload(e.target.files);
119
+ // Reset the input so re-selecting the same file fires `change`
120
+ // again — the browser dedupes by value otherwise.
121
+ e.target.value = "";
122
+ }
123
+
124
+ return (
125
+ <div className={cn("space-y-3", className)}>
126
+ <div
127
+ role="button"
128
+ tabIndex={0}
129
+ onClick={() => inputRef.current?.click()}
130
+ onKeyDown={(e) => {
131
+ if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
132
+ }}
133
+ onDragOver={(e) => {
134
+ e.preventDefault();
135
+ setDragging(true);
136
+ }}
137
+ onDragLeave={() => setDragging(false)}
138
+ onDrop={onDrop}
139
+ className={cn(
140
+ "flex cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border border-dashed px-6 py-10 text-center transition-colors",
141
+ dragging
142
+ ? "border-[var(--pylon-ink,#0a0a0a)] bg-[var(--pylon-paper-2,#f4f4f5)]"
143
+ : "border-[var(--pylon-rule,#e5e7eb)] bg-[var(--pylon-paper,#ffffff)] hover:bg-[var(--pylon-paper-2,#f4f4f5)]",
144
+ )}
145
+ >
146
+ <p className="text-sm font-medium text-[var(--pylon-ink,#0a0a0a)]">
147
+ {label}
148
+ </p>
149
+ <p className="text-xs text-[var(--pylon-ink-3,#71717a)]">
150
+ {helperText}
151
+ </p>
152
+ <input
153
+ ref={inputRef}
154
+ type="file"
155
+ accept={accept}
156
+ multiple={multiple}
157
+ onChange={onChange}
158
+ className="sr-only"
159
+ />
160
+ </div>
161
+ {items.length > 0 ? (
162
+ <ul className="divide-y divide-[var(--pylon-rule,#e5e7eb)] rounded-md border border-[var(--pylon-rule,#e5e7eb)]">
163
+ {items.map((item, i) => (
164
+ <li
165
+ key={`${item.file.name}-${i}`}
166
+ className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
167
+ >
168
+ <span className="min-w-0">
169
+ <span className="block truncate font-medium text-[var(--pylon-ink,#0a0a0a)]">
170
+ {item.file.name}
171
+ </span>
172
+ <span className="block text-[11px] text-[var(--pylon-ink-3,#71717a)]">
173
+ {humanSize(item.file.size)}
174
+ {item.error ? ` · ${item.error}` : ""}
175
+ </span>
176
+ </span>
177
+ <UploadStatus result={item} />
178
+ </li>
179
+ ))}
180
+ </ul>
181
+ ) : null}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ function UploadStatus({ result }: { result: FileUploadResult }) {
187
+ if (result.uploaded) {
188
+ return (
189
+ <span className="rounded-md bg-[var(--pylon-paper-2,#f4f4f5)] px-2 py-0.5 text-[11px] font-medium text-[var(--pylon-ink-2,#52525b)]">
190
+ Uploaded
191
+ </span>
192
+ );
193
+ }
194
+ if (result.status === "error") {
195
+ return (
196
+ <span className="rounded-md bg-[var(--pylon-error-bg,#fef2f2)] px-2 py-0.5 text-[11px] font-medium text-[var(--pylon-error-ink,#b91c1c)]">
197
+ Failed
198
+ </span>
199
+ );
200
+ }
201
+ return (
202
+ <span className="text-[11px] text-[var(--pylon-ink-3,#71717a)]">
203
+ Uploading…
204
+ </span>
205
+ );
206
+ }
207
+
208
+ function humanSize(bytes: number): string {
209
+ if (bytes < 1024) return `${bytes} B`;
210
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
211
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
212
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
213
+ }
@@ -0,0 +1,139 @@
1
+ "use client";
2
+ import React from "react";
3
+
4
+ import { type ReactNode, useEffect } from "react";
5
+ import { useAuth } from "../hooks/useAuth";
6
+
7
+ /**
8
+ * Render children only when the user is signed in. Drop-in match for
9
+ * Clerk's `<SignedIn>`.
10
+ */
11
+ export function SignedIn({ children }: { children: ReactNode }) {
12
+ const { isSignedIn } = useAuth();
13
+ return isSignedIn ? <>{children}</> : null;
14
+ }
15
+
16
+ /**
17
+ * Render children only when the user is signed out.
18
+ */
19
+ export function SignedOut({ children }: { children: ReactNode }) {
20
+ const { isSignedIn } = useAuth();
21
+ return isSignedIn ? null : <>{children}</>;
22
+ }
23
+
24
+ export interface ProtectProps {
25
+ /** Show fallback (or null) when the predicate fails. */
26
+ children: ReactNode;
27
+ /** What to render when the user isn't authorized. Defaults to nothing. */
28
+ fallback?: ReactNode;
29
+ /** Require admin. Equivalent to passing `predicate={(a) => a.isAdmin}`. */
30
+ admin?: boolean;
31
+ /** Custom gate. Receives the full `useAuth()` shape. */
32
+ predicate?: (auth: ReturnType<typeof useAuth>) => boolean;
33
+ }
34
+
35
+ /**
36
+ * Gate children behind a predicate. Defaults to "must be signed in";
37
+ * pass `admin` or a custom `predicate` for finer control. Server-side
38
+ * authorization is still enforced by the policy layer — this is purely a
39
+ * UX convenience to avoid flashing forbidden content.
40
+ */
41
+ export function Protect({
42
+ children,
43
+ fallback = null,
44
+ admin,
45
+ predicate,
46
+ }: ProtectProps) {
47
+ const auth = useAuth();
48
+ const allowed = predicate
49
+ ? predicate(auth)
50
+ : admin
51
+ ? auth.isSignedIn && auth.isAdmin
52
+ : auth.isSignedIn;
53
+ return allowed ? <>{children}</> : <>{fallback}</>;
54
+ }
55
+
56
+ export interface HasRoleProps {
57
+ /** Role string (e.g., "owner", "admin", "member"). Matches against the
58
+ * `roles` array on the resolved session, which the framework populates
59
+ * with the user's org role when an org is active. */
60
+ role: string | string[];
61
+ /** Match ANY (default) or ALL listed roles. */
62
+ mode?: "any" | "all";
63
+ children: ReactNode;
64
+ fallback?: ReactNode;
65
+ }
66
+
67
+ /**
68
+ * Render children only when the resolved session has one (or all) of the
69
+ * given roles. Server-side authorization is still the source of truth —
70
+ * this is purely a UX gate to hide buttons the API would refuse anyway.
71
+ */
72
+ export function HasRole({
73
+ role,
74
+ mode = "any",
75
+ children,
76
+ fallback = null,
77
+ }: HasRoleProps) {
78
+ const { session } = useAuth();
79
+ const roles = session?.roles ?? [];
80
+ const wanted = Array.isArray(role) ? role : [role];
81
+ const allowed =
82
+ mode === "all"
83
+ ? wanted.every((r) => roles.includes(r))
84
+ : wanted.some((r) => roles.includes(r));
85
+ return allowed ? <>{children}</> : <>{fallback}</>;
86
+ }
87
+
88
+ /** Render children only when the user has an active org selected. */
89
+ export function InOrg({
90
+ children,
91
+ fallback = null,
92
+ }: {
93
+ children: ReactNode;
94
+ fallback?: ReactNode;
95
+ }) {
96
+ const { tenantId } = useAuth();
97
+ return tenantId ? <>{children}</> : <>{fallback}</>;
98
+ }
99
+
100
+ /** Render children only when no org is selected (personal mode). */
101
+ export function NoOrg({
102
+ children,
103
+ fallback = null,
104
+ }: {
105
+ children: ReactNode;
106
+ fallback?: ReactNode;
107
+ }) {
108
+ const { tenantId } = useAuth();
109
+ return tenantId ? <>{fallback}</> : <>{children}</>;
110
+ }
111
+
112
+ export interface RedirectToSignInProps {
113
+ /** Sign-in route (default: "/sign-in"). */
114
+ signInUrl?: string;
115
+ /** Encode the current URL into a `next` param so the sign-in page can
116
+ * bounce the user back. Default: true. */
117
+ preserveLocation?: boolean;
118
+ }
119
+
120
+ /**
121
+ * Imperative client-side redirect — drop inside `<SignedOut>` to send an
122
+ * anonymous visitor to the sign-in page. Uses `window.location.assign`
123
+ * (full nav) since we don't assume the consumer's router shape; apps
124
+ * that want a soft navigation should write their own using `useAuth`.
125
+ */
126
+ export function RedirectToSignIn({
127
+ signInUrl = "/sign-in",
128
+ preserveLocation = true,
129
+ }: RedirectToSignInProps) {
130
+ useEffect(() => {
131
+ if (typeof window === "undefined") return;
132
+ const next =
133
+ preserveLocation && window.location?.href
134
+ ? `?next=${encodeURIComponent(window.location.href)}`
135
+ : "";
136
+ window.location.assign(`${signInUrl}${next}`);
137
+ }, [signInUrl, preserveLocation]);
138
+ return null;
139
+ }