@omg-dev/sdk 0.4.24
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/dist/OmgBadge-LAcimQp0.mjs +345 -0
- package/dist/VibesFeedback-BF2Vf6FK.mjs +808 -0
- package/dist/brand/auto.mjs +18 -0
- package/dist/feedback/auto.mjs +26 -0
- package/dist/index.mjs +1611 -0
- package/package.json +43 -0
- package/src/auth/auto-prompt.tsx +50 -0
- package/src/auth/bridge.ts +63 -0
- package/src/auth/client.ts +222 -0
- package/src/auth/fetch.ts +23 -0
- package/src/auth/guard.tsx +24 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/login.tsx +267 -0
- package/src/auth/mail-apps.ts +52 -0
- package/src/auth/react.tsx +248 -0
- package/src/brand/OmgBadge.tsx +366 -0
- package/src/brand/auto.tsx +35 -0
- package/src/brand/index.ts +1 -0
- package/src/feedback/VibesFeedback.tsx +360 -0
- package/src/feedback/auto.tsx +47 -0
- package/src/feedback/gestures.ts +296 -0
- package/src/feedback/index.ts +18 -0
- package/src/feedback/screenshot.ts +37 -0
- package/src/feedback/trace.ts +166 -0
- package/src/index.ts +1042 -0
- package/src/notifications/index.tsx +179 -0
- package/src/sandbox.test.ts +61 -0
- package/src/sandbox.ts +106 -0
- package/src/storage/VibesUpload.tsx +140 -0
- package/src/storage/index.ts +12 -0
- package/src/storage/useUpload.ts +167 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react"
|
|
2
|
+
import { useQuery, type SubscribePredicate } from "../index"
|
|
3
|
+
import { getAuthContext, notifyAuthRequired } from "../auth/bridge"
|
|
4
|
+
|
|
5
|
+
export interface VibesNotification {
|
|
6
|
+
id: string
|
|
7
|
+
appId: string
|
|
8
|
+
userId: string
|
|
9
|
+
kind: string
|
|
10
|
+
title: string
|
|
11
|
+
body: string
|
|
12
|
+
url: string
|
|
13
|
+
status: "unread" | "read" | "archived"
|
|
14
|
+
priority: "low" | "normal" | "high"
|
|
15
|
+
sourceType: string
|
|
16
|
+
sourceId: string
|
|
17
|
+
dedupeKey: string
|
|
18
|
+
dataJson: string
|
|
19
|
+
readAt: number
|
|
20
|
+
createdAt: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function authHeaders(extra?: HeadersInit): Headers {
|
|
24
|
+
const h = new Headers(extra)
|
|
25
|
+
const { token } = getAuthContext()
|
|
26
|
+
if (token) h.set("Authorization", `Bearer ${token}`)
|
|
27
|
+
return h
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function notificationFetch(input: string, init?: RequestInit): Promise<Response> {
|
|
31
|
+
const res = await fetch(input, { ...init, headers: authHeaders(init?.headers) })
|
|
32
|
+
if (res.status === 401) {
|
|
33
|
+
if (getAuthContext().authReady) notifyAuthRequired()
|
|
34
|
+
throw new Error("Authentication required")
|
|
35
|
+
}
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
let msg = `request failed (${res.status})`
|
|
38
|
+
try {
|
|
39
|
+
const body = await res.clone().json()
|
|
40
|
+
if (body && typeof body.error === "string") msg = body.error
|
|
41
|
+
} catch {}
|
|
42
|
+
throw new Error(msg)
|
|
43
|
+
}
|
|
44
|
+
return res
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function base64UrlToUint8Array(value: string): Uint8Array {
|
|
48
|
+
const padded = `${value}${"=".repeat((4 - (value.length % 4)) % 4)}`
|
|
49
|
+
const base64 = padded.replace(/-/g, "+").replace(/_/g, "/")
|
|
50
|
+
const raw = atob(base64)
|
|
51
|
+
const out = new Uint8Array(raw.length)
|
|
52
|
+
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i)
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function notificationSupport() {
|
|
57
|
+
if (typeof window === "undefined" || typeof navigator === "undefined") {
|
|
58
|
+
return { supported: false, permission: "default" as NotificationPermission }
|
|
59
|
+
}
|
|
60
|
+
const supported =
|
|
61
|
+
"Notification" in window &&
|
|
62
|
+
"serviceWorker" in navigator &&
|
|
63
|
+
"PushManager" in window
|
|
64
|
+
return {
|
|
65
|
+
supported,
|
|
66
|
+
permission: supported ? Notification.permission : "default" as NotificationPermission,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useNotificationPermission() {
|
|
71
|
+
const initial = notificationSupport()
|
|
72
|
+
const [permission, setPermission] = useState<NotificationPermission>(initial.permission)
|
|
73
|
+
const [busy, setBusy] = useState(false)
|
|
74
|
+
const supported = initial.supported
|
|
75
|
+
|
|
76
|
+
const enablePush = useCallback(async (): Promise<boolean> => {
|
|
77
|
+
if (!supported) return false
|
|
78
|
+
setBusy(true)
|
|
79
|
+
try {
|
|
80
|
+
const configRes = await notificationFetch("/api/_notifications/config")
|
|
81
|
+
const config = await configRes.json() as { vapidPublicKey?: string }
|
|
82
|
+
if (!config.vapidPublicKey) return false
|
|
83
|
+
|
|
84
|
+
const nextPermission =
|
|
85
|
+
Notification.permission === "default"
|
|
86
|
+
? await Notification.requestPermission()
|
|
87
|
+
: Notification.permission
|
|
88
|
+
setPermission(nextPermission)
|
|
89
|
+
if (nextPermission !== "granted") return false
|
|
90
|
+
|
|
91
|
+
const registration = await navigator.serviceWorker.register("/__vibes_push/sw.js", {
|
|
92
|
+
scope: "/__vibes_push/",
|
|
93
|
+
})
|
|
94
|
+
const existing = await registration.pushManager.getSubscription()
|
|
95
|
+
const subscription = existing ?? await registration.pushManager.subscribe({
|
|
96
|
+
userVisibleOnly: true,
|
|
97
|
+
applicationServerKey: base64UrlToUint8Array(config.vapidPublicKey),
|
|
98
|
+
})
|
|
99
|
+
await notificationFetch("/api/_notifications/subscribe", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "content-type": "application/json" },
|
|
102
|
+
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
|
103
|
+
})
|
|
104
|
+
return true
|
|
105
|
+
} finally {
|
|
106
|
+
setBusy(false)
|
|
107
|
+
}
|
|
108
|
+
}, [supported])
|
|
109
|
+
|
|
110
|
+
return { supported, permission, busy, enablePush }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function useNotifications(options?: { unreadOnly?: boolean }) {
|
|
114
|
+
const where = useMemo<SubscribePredicate | undefined>(() => {
|
|
115
|
+
if (!options?.unreadOnly) return undefined
|
|
116
|
+
return { op: "eq", column: "status", value: "unread" }
|
|
117
|
+
}, [options?.unreadOnly])
|
|
118
|
+
|
|
119
|
+
const query = useQuery<VibesNotification>({
|
|
120
|
+
collection: "vibesNotifications",
|
|
121
|
+
api: options?.unreadOnly ? "/api/_notifications/list?unread=1" : "/api/_notifications/list",
|
|
122
|
+
where,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const notifications = useMemo(
|
|
126
|
+
() => [...query.data].sort((a, b) => b.createdAt - a.createdAt),
|
|
127
|
+
[query.data],
|
|
128
|
+
)
|
|
129
|
+
const unreadCount = useMemo(
|
|
130
|
+
() => notifications.reduce((n, item) => n + (item.status === "unread" ? 1 : 0), 0),
|
|
131
|
+
[notifications],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const markRead = useCallback(async (ids: string[]) => {
|
|
135
|
+
if (ids.length === 0) return
|
|
136
|
+
await notificationFetch("/api/_notifications/read", {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "content-type": "application/json" },
|
|
139
|
+
body: JSON.stringify({ ids }),
|
|
140
|
+
})
|
|
141
|
+
query.refresh()
|
|
142
|
+
}, [query])
|
|
143
|
+
|
|
144
|
+
const markAllRead = useCallback(async () => {
|
|
145
|
+
await notificationFetch("/api/_notifications/read", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "content-type": "application/json" },
|
|
148
|
+
body: JSON.stringify({ all: true }),
|
|
149
|
+
})
|
|
150
|
+
query.refresh()
|
|
151
|
+
}, [query])
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
...query,
|
|
155
|
+
notifications,
|
|
156
|
+
unreadCount,
|
|
157
|
+
markRead,
|
|
158
|
+
markAllRead,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function createNotification(input: {
|
|
163
|
+
kind?: string
|
|
164
|
+
title: string
|
|
165
|
+
body?: string
|
|
166
|
+
url?: string
|
|
167
|
+
priority?: "low" | "normal" | "high"
|
|
168
|
+
sourceType?: string
|
|
169
|
+
sourceId?: string
|
|
170
|
+
dedupeKey?: string
|
|
171
|
+
data?: Record<string, unknown>
|
|
172
|
+
}) {
|
|
173
|
+
const res = await notificationFetch("/api/_notifications/create", {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "content-type": "application/json" },
|
|
176
|
+
body: JSON.stringify(input),
|
|
177
|
+
})
|
|
178
|
+
return res.json() as Promise<VibesNotification | null>
|
|
179
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createSandbox, forkSandbox, runtimeClaims } from "./sandbox";
|
|
3
|
+
|
|
4
|
+
describe("sandbox SDK", () => {
|
|
5
|
+
it("posts create requests to the sandbox router", async () => {
|
|
6
|
+
const calls: Array<{ url: string; body: unknown }> = [];
|
|
7
|
+
const fetchImpl = async (url: string, init?: RequestInit) => {
|
|
8
|
+
calls.push({ url, body: JSON.parse(String(init?.body)) });
|
|
9
|
+
return new Response(JSON.stringify({ id: "child-sb" }), {
|
|
10
|
+
status: 200,
|
|
11
|
+
headers: { "content-type": "application/json" },
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const got = await createSandbox(
|
|
16
|
+
{ ports: [5173], runtimeClaims: [runtimeClaims.llmInvoke] },
|
|
17
|
+
{
|
|
18
|
+
routerBase: "http://agent.test/_sandbox",
|
|
19
|
+
fetch: fetchImpl as typeof fetch,
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect(got.id).toBe("child-sb");
|
|
24
|
+
expect(calls).toEqual([
|
|
25
|
+
{
|
|
26
|
+
url: "http://agent.test/_sandbox/create",
|
|
27
|
+
body: { ports: [5173], runtimeClaims: ["llm.invoke"] },
|
|
28
|
+
},
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("requires snapshotId before posting fork requests", () => {
|
|
33
|
+
expect(() =>
|
|
34
|
+
forkSandbox(
|
|
35
|
+
{ snapshotId: "" },
|
|
36
|
+
{ fetch: (() => Promise.reject(new Error("unused"))) as typeof fetch },
|
|
37
|
+
),
|
|
38
|
+
).toThrow("snapshotId");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("surfaces router JSON errors", async () => {
|
|
42
|
+
const fetchImpl = async () =>
|
|
43
|
+
new Response(
|
|
44
|
+
JSON.stringify({ error: "parent sandbox cannot create sandboxes" }),
|
|
45
|
+
{
|
|
46
|
+
status: 403,
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
await expect(
|
|
52
|
+
createSandbox(
|
|
53
|
+
{},
|
|
54
|
+
{
|
|
55
|
+
routerBase: "http://agent.test/_sandbox",
|
|
56
|
+
fetch: fetchImpl as typeof fetch,
|
|
57
|
+
},
|
|
58
|
+
),
|
|
59
|
+
).rejects.toThrow("parent sandbox cannot create sandboxes");
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const runtimeClaims = {
|
|
2
|
+
llmInvoke: "llm.invoke",
|
|
3
|
+
mediaInvoke: "media.invoke",
|
|
4
|
+
browserUse: "browser.use",
|
|
5
|
+
sandboxCreate: "sandbox.create",
|
|
6
|
+
sandboxDelegate: "sandbox.delegate",
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export type RuntimeClaim = (typeof runtimeClaims)[keyof typeof runtimeClaims];
|
|
10
|
+
|
|
11
|
+
export interface SandboxInfo {
|
|
12
|
+
id: string;
|
|
13
|
+
status?: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
ports?: Record<string, number>;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CreateSandboxOptions {
|
|
20
|
+
ports?: number[];
|
|
21
|
+
templateId?: string;
|
|
22
|
+
runtimeClaims?: RuntimeClaim[];
|
|
23
|
+
agentServerSource?: string;
|
|
24
|
+
skipAppProcesses?: boolean;
|
|
25
|
+
agentBootstrap?: unknown;
|
|
26
|
+
sessionId?: string;
|
|
27
|
+
preferredModel?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ForkSandboxOptions {
|
|
31
|
+
snapshotId: string;
|
|
32
|
+
ports?: number[];
|
|
33
|
+
runtimeClaims?: RuntimeClaim[];
|
|
34
|
+
agentServerSource?: string;
|
|
35
|
+
sessionId?: string;
|
|
36
|
+
preferredModel?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SandboxRouterOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Override for tests or custom runtimes. In a Vibes sandbox this defaults to
|
|
42
|
+
* the local in-VM agent router at http://localhost:8080/_sandbox.
|
|
43
|
+
*/
|
|
44
|
+
routerBase?: string;
|
|
45
|
+
fetch?: typeof fetch;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultSandboxRouterBase(): string {
|
|
49
|
+
const envBase =
|
|
50
|
+
typeof process !== "undefined"
|
|
51
|
+
? process.env?.VIBES_SANDBOX_ROUTER_URL || process.env?.VIBES_AGENT_URL
|
|
52
|
+
: undefined;
|
|
53
|
+
if (envBase) return envBase.replace(/\/$/, "");
|
|
54
|
+
if (typeof window !== "undefined") {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"sandbox SDK calls must run in a server function inside a Vibes sandbox",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return "http://localhost:8080/_sandbox";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function postSandboxRouter<T>(
|
|
63
|
+
path: string,
|
|
64
|
+
body: unknown,
|
|
65
|
+
opts: SandboxRouterOptions = {},
|
|
66
|
+
): Promise<T> {
|
|
67
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
68
|
+
const base = (opts.routerBase ?? defaultSandboxRouterBase()).replace(
|
|
69
|
+
/\/$/,
|
|
70
|
+
"",
|
|
71
|
+
);
|
|
72
|
+
const res = await fetchImpl(`${base}${path}`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json" },
|
|
75
|
+
body: JSON.stringify(body ?? {}),
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
let message = `sandbox router ${res.status}`;
|
|
79
|
+
try {
|
|
80
|
+
const parsed = await res.clone().json();
|
|
81
|
+
if (parsed && typeof parsed.error === "string") message = parsed.error;
|
|
82
|
+
} catch {
|
|
83
|
+
const text = await res.text().catch(() => "");
|
|
84
|
+
if (text) message = text;
|
|
85
|
+
}
|
|
86
|
+
throw new Error(message);
|
|
87
|
+
}
|
|
88
|
+
return res.json();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSandbox(
|
|
92
|
+
options: CreateSandboxOptions = {},
|
|
93
|
+
routerOptions?: SandboxRouterOptions,
|
|
94
|
+
): Promise<SandboxInfo> {
|
|
95
|
+
return postSandboxRouter<SandboxInfo>("/create", options, routerOptions);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function forkSandbox(
|
|
99
|
+
options: ForkSandboxOptions,
|
|
100
|
+
routerOptions?: SandboxRouterOptions,
|
|
101
|
+
): Promise<SandboxInfo> {
|
|
102
|
+
if (!options?.snapshotId) {
|
|
103
|
+
throw new Error("forkSandbox requires snapshotId");
|
|
104
|
+
}
|
|
105
|
+
return postSandboxRouter<SandboxInfo>("/fork", options, routerOptions);
|
|
106
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useRef, useState, useId, useCallback, type CSSProperties, type DragEvent, type ChangeEvent } from "react"
|
|
2
|
+
import { useUpload, type UploadOptions, type UploadResult } from "./useUpload"
|
|
3
|
+
|
|
4
|
+
export interface VibesUploadProps {
|
|
5
|
+
/**
|
|
6
|
+
* Accept attribute for the file input (e.g. "image/*"). Mirrored on the
|
|
7
|
+
* drop-zone so the browser shows a no-drop cursor for the wrong type.
|
|
8
|
+
*/
|
|
9
|
+
accept?: string
|
|
10
|
+
/**
|
|
11
|
+
* Function that picks a key for the uploaded file. Defaults to
|
|
12
|
+
* `uploads/<crypto.randomUUID>-<filename>`.
|
|
13
|
+
*/
|
|
14
|
+
keyFor?: (file: File) => string
|
|
15
|
+
/** "user" (default) or "app". */
|
|
16
|
+
scope?: "user" | "app"
|
|
17
|
+
/** Fired after a successful upload. Use this to write the file's key into a DB row. */
|
|
18
|
+
onUploaded?: (result: UploadResult, file: File) => void
|
|
19
|
+
/** Fired when an upload fails. */
|
|
20
|
+
onError?: (error: Error) => void
|
|
21
|
+
/** Label rendered inside the drop zone. Defaults to "Upload" / drag instructions. */
|
|
22
|
+
label?: string
|
|
23
|
+
/** Disable the picker. */
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
/** Style overrides. The component ships unopinionated, hostable styles. */
|
|
26
|
+
style?: CSSProperties
|
|
27
|
+
className?: string
|
|
28
|
+
/** Override the presign endpoint (defaults to /api/_storage/presign). */
|
|
29
|
+
presignUrl?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultKeyFor(file: File): string {
|
|
33
|
+
const safe = file.name.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
34
|
+
const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2)
|
|
35
|
+
return `uploads/${id}-${safe}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Drop-zone + file picker. Calls onUploaded with the stored key + signed
|
|
40
|
+
* download URL on success. Renders nothing during prerender (SSR) — the
|
|
41
|
+
* hook depends on browser-only APIs (XHR, FormData).
|
|
42
|
+
*/
|
|
43
|
+
export function VibesUpload(props: VibesUploadProps): JSX.Element {
|
|
44
|
+
const { upload, uploading, progress, error } = useUpload()
|
|
45
|
+
const [dragOver, setDragOver] = useState(false)
|
|
46
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
47
|
+
const inputId = useId()
|
|
48
|
+
|
|
49
|
+
const handleFiles = useCallback(async (files: FileList | File[]) => {
|
|
50
|
+
const list = Array.from(files)
|
|
51
|
+
for (const file of list) {
|
|
52
|
+
const opts: UploadOptions = {
|
|
53
|
+
key: (props.keyFor ?? defaultKeyFor)(file),
|
|
54
|
+
contentType: file.type || "application/octet-stream",
|
|
55
|
+
scope: props.scope ?? "user",
|
|
56
|
+
presignUrl: props.presignUrl,
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const result = await upload(file, opts)
|
|
60
|
+
props.onUploaded?.(result, file)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
props.onError?.(err as Error)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}, [props, upload])
|
|
66
|
+
|
|
67
|
+
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
68
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
69
|
+
void handleFiles(e.target.files)
|
|
70
|
+
e.target.value = "" // reset so re-selecting the same file fires onChange again
|
|
71
|
+
}
|
|
72
|
+
}, [handleFiles])
|
|
73
|
+
|
|
74
|
+
const onDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
setDragOver(false)
|
|
77
|
+
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
78
|
+
void handleFiles(e.dataTransfer.files)
|
|
79
|
+
}
|
|
80
|
+
}, [handleFiles])
|
|
81
|
+
|
|
82
|
+
const onDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
if (!dragOver) setDragOver(true)
|
|
85
|
+
}, [dragOver])
|
|
86
|
+
|
|
87
|
+
const onDragLeave = useCallback(() => setDragOver(false), [])
|
|
88
|
+
|
|
89
|
+
const baseStyle: CSSProperties = {
|
|
90
|
+
border: `1px dashed ${dragOver ? "#2563eb" : "rgba(0,0,0,0.18)"}`,
|
|
91
|
+
background: dragOver ? "rgba(37, 99, 235, 0.05)" : "transparent",
|
|
92
|
+
borderRadius: 10,
|
|
93
|
+
padding: "16px 18px",
|
|
94
|
+
display: "flex",
|
|
95
|
+
flexDirection: "column",
|
|
96
|
+
gap: 6,
|
|
97
|
+
alignItems: "center",
|
|
98
|
+
justifyContent: "center",
|
|
99
|
+
cursor: props.disabled ? "not-allowed" : "pointer",
|
|
100
|
+
opacity: props.disabled ? 0.5 : 1,
|
|
101
|
+
fontSize: 14,
|
|
102
|
+
color: "rgba(0,0,0,0.7)",
|
|
103
|
+
minHeight: 88,
|
|
104
|
+
userSelect: "none",
|
|
105
|
+
transition: "border-color 80ms ease, background 80ms ease",
|
|
106
|
+
...props.style,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<label htmlFor={inputId} style={baseStyle} className={props.className}
|
|
111
|
+
onDrop={onDrop} onDragOver={onDragOver} onDragLeave={onDragLeave}
|
|
112
|
+
data-uploading={uploading || undefined}>
|
|
113
|
+
<input
|
|
114
|
+
ref={inputRef}
|
|
115
|
+
id={inputId}
|
|
116
|
+
type="file"
|
|
117
|
+
accept={props.accept}
|
|
118
|
+
onChange={onChange}
|
|
119
|
+
disabled={props.disabled || uploading}
|
|
120
|
+
style={{ display: "none" }}
|
|
121
|
+
/>
|
|
122
|
+
{uploading ? (
|
|
123
|
+
<>
|
|
124
|
+
<span>Uploading…</span>
|
|
125
|
+
<span style={{ fontSize: 12, opacity: 0.6 }}>{Math.round(progress * 100)}%</span>
|
|
126
|
+
</>
|
|
127
|
+
) : error ? (
|
|
128
|
+
<>
|
|
129
|
+
<span style={{ color: "#dc2626" }}>Upload failed</span>
|
|
130
|
+
<span style={{ fontSize: 12, opacity: 0.7 }}>{error}</span>
|
|
131
|
+
</>
|
|
132
|
+
) : (
|
|
133
|
+
<>
|
|
134
|
+
<span>{props.label ?? "Drop file or click to upload"}</span>
|
|
135
|
+
<span style={{ fontSize: 12, opacity: 0.55 }}>{props.accept ?? "any file"} · up to 25MB</span>
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
138
|
+
</label>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @omg-dev/sdk storage — client-side hook + component for uploading files to a
|
|
2
|
+
// vibes app. Pairs with @omg-dev/server's `storage` API on the server.
|
|
3
|
+
//
|
|
4
|
+
// Auth model: every call (presign + actual PUT) carries the user's bearer
|
|
5
|
+
// token from the auth bridge. Files default to scope:"user" — the server-side
|
|
6
|
+
// presign endpoint scopes the storage key to the authed user's id, so a
|
|
7
|
+
// browser without a logged-in user gets a 401 from the presign route.
|
|
8
|
+
|
|
9
|
+
export { useUpload } from "./useUpload"
|
|
10
|
+
export type { UploadOptions, UploadResult, UploadState } from "./useUpload"
|
|
11
|
+
export { VibesUpload } from "./VibesUpload"
|
|
12
|
+
export type { VibesUploadProps } from "./VibesUpload"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useCallback, useState } from "react"
|
|
2
|
+
import { getAuthContext, notifyAuthRequired } from "../auth/bridge"
|
|
3
|
+
|
|
4
|
+
export interface UploadOptions {
|
|
5
|
+
/** Object key (e.g. "avatar.png", "uploads/photo.jpg"). */
|
|
6
|
+
key: string
|
|
7
|
+
/** Content-Type for the PUT. Auto-detected from File when omitted. */
|
|
8
|
+
contentType?: string
|
|
9
|
+
/** "user" (default) or "app". */
|
|
10
|
+
scope?: "user" | "app"
|
|
11
|
+
/**
|
|
12
|
+
* Endpoint that mints the presigned URL. Defaults to "/api/_storage/presign",
|
|
13
|
+
* which the vibes vite-plugin auto-injects. Pass a custom path if you wrote
|
|
14
|
+
* your own functions/_storage.ts handler.
|
|
15
|
+
*/
|
|
16
|
+
presignUrl?: string
|
|
17
|
+
/** Optional callback for progress (0..1) during the PUT. */
|
|
18
|
+
onProgress?: (fraction: number) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UploadResult {
|
|
22
|
+
/** Public-ish key returned by the server — store this on the row that references the file. */
|
|
23
|
+
key: string
|
|
24
|
+
/** Pre-signed download URL valid for an hour. Use to render a preview. */
|
|
25
|
+
downloadUrl: string
|
|
26
|
+
size: number
|
|
27
|
+
contentType: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UploadState {
|
|
31
|
+
uploading: boolean
|
|
32
|
+
progress: number
|
|
33
|
+
error: string | null
|
|
34
|
+
result: UploadResult | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bearerHeaders(): Record<string, string> {
|
|
38
|
+
const { token } = getAuthContext()
|
|
39
|
+
return token ? { Authorization: `Bearer ${token}` } : {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Headless upload hook. Returns the upload function plus state.
|
|
44
|
+
*
|
|
45
|
+
* Usage:
|
|
46
|
+
* const { upload, uploading, progress, error } = useUpload()
|
|
47
|
+
* await upload(file, { key: `avatars/${user.id}.png` })
|
|
48
|
+
*/
|
|
49
|
+
export function useUpload(): UploadState & {
|
|
50
|
+
upload: (file: File | Blob, opts: UploadOptions) => Promise<UploadResult>
|
|
51
|
+
reset: () => void
|
|
52
|
+
} {
|
|
53
|
+
const [state, setState] = useState<UploadState>({
|
|
54
|
+
uploading: false,
|
|
55
|
+
progress: 0,
|
|
56
|
+
error: null,
|
|
57
|
+
result: null,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const reset = useCallback(() => {
|
|
61
|
+
setState({ uploading: false, progress: 0, error: null, result: null })
|
|
62
|
+
}, [])
|
|
63
|
+
|
|
64
|
+
const upload = useCallback(async (file: File | Blob, opts: UploadOptions): Promise<UploadResult> => {
|
|
65
|
+
setState({ uploading: true, progress: 0, error: null, result: null })
|
|
66
|
+
const presignUrl = opts.presignUrl ?? "/api/_storage/presign"
|
|
67
|
+
const contentType = opts.contentType ?? (file as File).type ?? "application/octet-stream"
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const presignRes = await fetch(presignUrl, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "Content-Type": "application/json", ...bearerHeaders() },
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
action: "put",
|
|
75
|
+
key: opts.key,
|
|
76
|
+
contentType,
|
|
77
|
+
scope: opts.scope ?? "user",
|
|
78
|
+
}),
|
|
79
|
+
})
|
|
80
|
+
if (!presignRes.ok) {
|
|
81
|
+
if (presignRes.status === 401 && getAuthContext().authReady) notifyAuthRequired()
|
|
82
|
+
const text = await presignRes.text().catch(() => "")
|
|
83
|
+
throw new Error(`presign ${presignRes.status}: ${text.slice(0, 200)}`)
|
|
84
|
+
}
|
|
85
|
+
const { url, key: storedKey, maxBytes } = (await presignRes.json()) as {
|
|
86
|
+
url: string
|
|
87
|
+
key: string
|
|
88
|
+
maxBytes?: number
|
|
89
|
+
}
|
|
90
|
+
if (typeof maxBytes === "number" && file.size > maxBytes) {
|
|
91
|
+
throw new Error(`file too large (${file.size} > ${maxBytes} bytes)`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// PUT with progress via XHR — fetch() doesn't expose request-side progress.
|
|
95
|
+
await new Promise<void>((resolve, reject) => {
|
|
96
|
+
const xhr = new XMLHttpRequest()
|
|
97
|
+
xhr.open("PUT", url)
|
|
98
|
+
xhr.setRequestHeader("Content-Type", contentType)
|
|
99
|
+
xhr.upload.onprogress = (e) => {
|
|
100
|
+
if (e.lengthComputable) {
|
|
101
|
+
const frac = e.loaded / e.total
|
|
102
|
+
setState(s => ({ ...s, progress: frac }))
|
|
103
|
+
opts.onProgress?.(frac)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
xhr.onload = () => {
|
|
107
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
108
|
+
resolve()
|
|
109
|
+
} else {
|
|
110
|
+
reject(new Error(`PUT ${xhr.status}: ${xhr.responseText.slice(0, 200)}`))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
xhr.onerror = () => reject(new Error("network error during upload"))
|
|
114
|
+
xhr.send(file)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Resolve a download URL so the caller can immediately render the new file.
|
|
118
|
+
const dlRes = await fetch(presignUrl, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json", ...bearerHeaders() },
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
action: "get",
|
|
123
|
+
key: opts.key,
|
|
124
|
+
scope: opts.scope ?? "user",
|
|
125
|
+
}),
|
|
126
|
+
})
|
|
127
|
+
let downloadUrl = ""
|
|
128
|
+
if (dlRes.ok) {
|
|
129
|
+
const dl = (await dlRes.json()) as { url?: string }
|
|
130
|
+
downloadUrl = dl.url ?? ""
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fire-and-forget: tell the app server the upload landed so any
|
|
134
|
+
// `storage.onUpload(...)` handler in functions/*.ts gets invoked.
|
|
135
|
+
// Failure here doesn't block the upload — the file already exists
|
|
136
|
+
// in Tigris, the hook just won't fire.
|
|
137
|
+
void fetch(presignUrl.replace(/\/presign$/, "/notify"), {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/json", ...bearerHeaders() },
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
action: "upload",
|
|
142
|
+
key: opts.key,
|
|
143
|
+
size: file.size,
|
|
144
|
+
contentType,
|
|
145
|
+
scope: opts.scope ?? "user",
|
|
146
|
+
}),
|
|
147
|
+
}).catch(() => {
|
|
148
|
+
// Notify failure is non-fatal; the upload itself succeeded.
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const result: UploadResult = {
|
|
152
|
+
key: storedKey,
|
|
153
|
+
downloadUrl,
|
|
154
|
+
size: file.size,
|
|
155
|
+
contentType,
|
|
156
|
+
}
|
|
157
|
+
setState({ uploading: false, progress: 1, error: null, result })
|
|
158
|
+
return result
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const msg = (err as Error).message ?? String(err)
|
|
161
|
+
setState({ uploading: false, progress: 0, error: msg, result: null })
|
|
162
|
+
throw err
|
|
163
|
+
}
|
|
164
|
+
}, [])
|
|
165
|
+
|
|
166
|
+
return { ...state, upload, reset }
|
|
167
|
+
}
|