@womp/mcp-ui 0.1.7 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@womp/mcp-ui",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "MCP Apps widgets for Womp's MCP server — interactive cards rendered inline in Claude and other MCP hosts.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "build": "yarn build:ui && yarn build:bake",
18
- "build:ui": "yarn build:ui:image && yarn build:ui:mesh && yarn build:ui:print && yarn build:ui:cart && yarn build:ui:order",
18
+ "build:ui": "yarn build:ui:image && yarn build:ui:mesh && yarn build:ui:print && yarn build:ui:cart && yarn build:ui:order && yarn build:ui:upload",
19
19
  "build:bake": "node scripts/bake-html.mjs",
20
20
  "dev": "vite",
21
21
  "dev:host": "vite",
@@ -27,7 +27,8 @@
27
27
  "build:ui:mesh": "CARD=mesh vite build",
28
28
  "build:ui:print": "CARD=print vite build",
29
29
  "build:ui:cart": "CARD=cart vite build",
30
- "build:ui:order": "CARD=order vite build"
30
+ "build:ui:order": "CARD=order vite build",
31
+ "build:ui:upload": "CARD=upload vite build"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@modelcontextprotocol/ext-apps": "^1.7.5",
@@ -0,0 +1,277 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Womp upload</title>
6
+ <style>
7
+ /* Same var()-with-fallback discipline as the other cards: host style
8
+ variables arrive over the bridge after the handshake, not as CSS
9
+ custom properties, so the card must look right without them. */
10
+ :root {
11
+ color-scheme: light dark;
12
+ --bg: var(--color-background-secondary, #1b1b1d);
13
+ --bg2: var(--color-background-tertiary, #27272b);
14
+ --fg: var(--color-text-primary, #ececec);
15
+ --fg2: var(--color-text-secondary, rgba(236, 236, 236, 0.62));
16
+ --line: var(--color-border-primary, rgba(255, 255, 255, 0.1));
17
+ --accent: var(--color-background-accent, #5867dd);
18
+ --ring: var(--color-ring-primary, #7aa2ff);
19
+ --danger: var(--color-text-danger, #ff8080);
20
+ --ok: #4fbf7a;
21
+ --warn: #e0a83a;
22
+ --r-md: var(--border-radius-md, 10px);
23
+ --r-lg: var(--border-radius-lg, 14px);
24
+ --fs-xs: var(--font-text-xs-size, 11px);
25
+ --fs-sm: var(--font-text-sm-size, 13px);
26
+ }
27
+ body {
28
+ margin: 0;
29
+ font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
30
+ color: var(--fg);
31
+ }
32
+ button {
33
+ font: inherit;
34
+ color: inherit;
35
+ }
36
+ [hidden] {
37
+ display: none !important;
38
+ }
39
+
40
+ .card {
41
+ max-width: 360px;
42
+ padding: 14px;
43
+ border-radius: var(--r-lg);
44
+ background: var(--bg);
45
+ border: 1px solid var(--line);
46
+ box-sizing: border-box;
47
+ display: grid;
48
+ grid-template-columns: minmax(0, 1fr);
49
+ gap: 12px;
50
+ }
51
+ :root[data-display-mode="fullscreen"] .card {
52
+ max-width: 560px;
53
+ margin: 16px auto;
54
+ }
55
+
56
+ .head {
57
+ display: grid;
58
+ gap: 2px;
59
+ }
60
+ .title {
61
+ font-size: var(--fs-sm);
62
+ font-weight: 600;
63
+ overflow-wrap: anywhere;
64
+ }
65
+ .sub {
66
+ font-size: var(--fs-xs);
67
+ color: var(--fg2);
68
+ }
69
+
70
+ /* --- The drop zone. Drag-and-drop from the desktop, or the button for a
71
+ picker; both hand the same File to the same upload. ------------- */
72
+ .drop {
73
+ display: grid;
74
+ justify-items: center;
75
+ gap: 8px;
76
+ padding: 18px 14px;
77
+ border: 1.5px dashed var(--line);
78
+ border-radius: var(--r-md);
79
+ background: var(--bg2);
80
+ text-align: center;
81
+ transition: border-color 120ms ease, background 120ms ease;
82
+ }
83
+ .drop.active {
84
+ border-color: var(--ring);
85
+ background: color-mix(in srgb, var(--ring) 12%, var(--bg2));
86
+ }
87
+ .drop-icon {
88
+ width: 26px;
89
+ height: 26px;
90
+ color: var(--fg2);
91
+ }
92
+ .drop-text {
93
+ font-size: var(--fs-sm);
94
+ }
95
+ .drop-hint {
96
+ font-size: var(--fs-xs);
97
+ color: var(--fg2);
98
+ }
99
+ .drop input {
100
+ display: none;
101
+ }
102
+
103
+ .btn {
104
+ height: 32px;
105
+ padding: 0 14px;
106
+ border-radius: var(--r-md);
107
+ border: 1px solid var(--line);
108
+ background: var(--bg);
109
+ cursor: pointer;
110
+ font-size: var(--fs-sm);
111
+ font-weight: 500;
112
+ }
113
+ .btn:hover:not(:disabled) {
114
+ border-color: var(--ring);
115
+ }
116
+ .btn:focus-visible {
117
+ outline: 2px solid var(--ring);
118
+ outline-offset: 1px;
119
+ }
120
+ .btn:disabled {
121
+ opacity: 0.6;
122
+ cursor: default;
123
+ }
124
+ .btn.primary {
125
+ background: var(--accent);
126
+ border-color: transparent;
127
+ color: #fff;
128
+ }
129
+
130
+ /* --- Progress. Holds at 99% until the server answers: the last byte
131
+ leaving the browser is not the end, the store still has to happen,
132
+ and a stalled "100%" reads as a hang. ---------------------------- */
133
+ .progress {
134
+ display: grid;
135
+ gap: 6px;
136
+ }
137
+ .bar {
138
+ height: 6px;
139
+ border-radius: 999px;
140
+ background: var(--bg2);
141
+ overflow: hidden;
142
+ }
143
+ .fill {
144
+ height: 100%;
145
+ width: 0%;
146
+ border-radius: inherit;
147
+ background: var(--accent);
148
+ transition: width 160ms ease;
149
+ }
150
+ .progress-text {
151
+ display: flex;
152
+ justify-content: space-between;
153
+ font-size: var(--fs-xs);
154
+ color: var(--fg2);
155
+ font-variant-numeric: tabular-nums;
156
+ }
157
+
158
+ /* --- Outcome strips: done (green), expired (amber), error (red). ------ */
159
+ .strip {
160
+ display: flex;
161
+ gap: 10px;
162
+ align-items: flex-start;
163
+ padding: 10px 12px;
164
+ border-radius: var(--r-md);
165
+ border-left: 3px solid var(--ok);
166
+ background: color-mix(in srgb, var(--ok) 12%, transparent);
167
+ font-size: var(--fs-sm);
168
+ }
169
+ .strip.warn {
170
+ border-left-color: var(--warn);
171
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
172
+ }
173
+ .strip.error {
174
+ border-left-color: var(--danger);
175
+ background: color-mix(in srgb, var(--danger) 12%, transparent);
176
+ }
177
+ .strip svg {
178
+ flex: none;
179
+ width: 16px;
180
+ height: 16px;
181
+ margin-top: 1px;
182
+ }
183
+ .strip .body {
184
+ display: grid;
185
+ gap: 4px;
186
+ min-width: 0;
187
+ }
188
+ .strip .body b {
189
+ overflow-wrap: anywhere;
190
+ }
191
+ .strip .muted {
192
+ font-size: var(--fs-xs);
193
+ color: var(--fg2);
194
+ }
195
+
196
+ .actions {
197
+ display: flex;
198
+ gap: 8px;
199
+ flex-wrap: wrap;
200
+ }
201
+
202
+ /* Names the failing layer instead of going blank — there is no console
203
+ inside the sandboxed iframe. Empty collapses. */
204
+ .status {
205
+ font-size: var(--fs-xs);
206
+ color: var(--fg2);
207
+ }
208
+ .status.error {
209
+ color: var(--danger);
210
+ }
211
+ .status:empty {
212
+ display: none;
213
+ }
214
+ </style>
215
+ </head>
216
+ <body>
217
+ <div class="card" id="card">
218
+ <div class="head">
219
+ <div class="title" id="title">Send your 3D model to Womp</div>
220
+ <div class="sub" id="sub">so it can be quoted and printed</div>
221
+ </div>
222
+
223
+ <div class="drop" id="drop" hidden>
224
+ <svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
225
+ <path d="M12 16V4m0 0-4 4m4-4 4 4" stroke-linecap="round" stroke-linejoin="round" />
226
+ <path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" stroke-linecap="round" />
227
+ </svg>
228
+ <div class="drop-text" id="drop-text">Drop the file here</div>
229
+ <button class="btn" id="choose" type="button">Choose file</button>
230
+ <div class="drop-hint" id="drop-hint"></div>
231
+ <input type="file" id="file" aria-label="3D model file" />
232
+ </div>
233
+
234
+ <div class="progress" id="progress" hidden>
235
+ <div class="bar"><div class="fill" id="fill"></div></div>
236
+ <div class="progress-text"><span id="progress-name"></span><span id="progress-pct">0%</span></div>
237
+ </div>
238
+
239
+ <div class="strip" id="done" hidden>
240
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
241
+ <path d="M5 12.5 9.5 17 19 7" stroke-linecap="round" stroke-linejoin="round" />
242
+ </svg>
243
+ <div class="body">
244
+ <div><b id="done-name"></b><span id="done-size"></span> is on Womp.</div>
245
+ <div class="muted">Reply in the chat and your assistant will quote the print.</div>
246
+ </div>
247
+ </div>
248
+
249
+ <div class="strip warn" id="expired" hidden>
250
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
251
+ <circle cx="12" cy="12" r="9" />
252
+ <path d="M12 7v5l3 2" stroke-linecap="round" />
253
+ </svg>
254
+ <div class="body">
255
+ <div>This upload link expired before <b id="expired-name"></b> arrived.</div>
256
+ <div class="muted">Ask your assistant for a new one.</div>
257
+ </div>
258
+ </div>
259
+
260
+ <div class="strip error" id="error" hidden>
261
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
262
+ <circle cx="12" cy="12" r="9" />
263
+ <path d="M12 8v5m0 3v.5" stroke-linecap="round" />
264
+ </svg>
265
+ <div class="body">
266
+ <div id="error-text"></div>
267
+ <div class="actions" id="error-actions" hidden>
268
+ <button class="btn" id="open-page" type="button">Open the upload page instead</button>
269
+ </div>
270
+ </div>
271
+ </div>
272
+
273
+ <div class="status" id="status">Connecting&hellip;</div>
274
+ </div>
275
+ <script type="module" src="./main.ts"></script>
276
+ </body>
277
+ </html>
@@ -0,0 +1,351 @@
1
+ import { $, callTool, createCard, openLink, type GenerationResult } from "../shared/bridge";
2
+
3
+ /**
4
+ * The upload card — renders `create_upload` / `get_upload` and lets the user
5
+ * drop a mesh file straight into the conversation.
6
+ *
7
+ * Why it exists: MCP has no client→server file channel, and a chat host's
8
+ * sandbox usually cannot reach Womp (Claude's egress allowlist is package
9
+ * managers only), so a file the user already attached to the chat could not
10
+ * reach the print pipeline without a trip to a womp.com page. This card is that
11
+ * page, inside the chat. The bytes go from the user's disk to the ticket URL
12
+ * the backend minted — a raw PUT, the same request the agent's own `curl -T`
13
+ * would make — and never through the model.
14
+ *
15
+ * What it needs from the host: `connectDomains` on the resource's CSP must
16
+ * include the API origin (swan-backend adds its own origin at registration —
17
+ * the baked CSP cannot know which environment it will serve), and the backend
18
+ * answers the PUT with open CORS because the iframe origin is opaque. If either
19
+ * is missing the PUT fails as a network error, which the card turns into the
20
+ * "open the upload page instead" fallback rather than a dead end.
21
+ *
22
+ * It also polls the app-callable `get_upload` while pending, so a file the
23
+ * AGENT pushed itself (an agent with a shell does that first) flips this card
24
+ * to done instead of leaving a drop zone for a file that already landed.
25
+ */
26
+
27
+ interface UploadSummary {
28
+ uploadId: number;
29
+ status: "pending" | "ready" | "expired";
30
+ fileName: string;
31
+ format: string;
32
+ maxBytes: number;
33
+ maxMb: number;
34
+ expiresAt: string;
35
+ uploadUrl: string;
36
+ browserUploadUrl: string;
37
+ fileSize?: number;
38
+ url?: string;
39
+ lastError?: string;
40
+ }
41
+
42
+ type Phase = "connecting" | "pending" | "uploading" | "done" | "expired";
43
+
44
+ const MB = 1024 * 1024;
45
+ const POLL_START_MS = 5_000;
46
+ const POLL_MAX_MS = 15_000;
47
+
48
+ const state: {
49
+ summary?: UploadSummary;
50
+ phase: Phase;
51
+ /** The name of the file being sent / that was sent — the drop's, not the ticket's. */
52
+ fileName?: string;
53
+ fileSize?: number;
54
+ } = { phase: "connecting" };
55
+
56
+ let pollTimer: number | undefined;
57
+
58
+ const setStatus = (text: string, isError = false) => {
59
+ const el = $("status");
60
+ el.textContent = text;
61
+ el.classList.toggle("error", isError);
62
+ };
63
+
64
+ /** "399 KB" below a megabyte, "1.4 MB" / "150 MB" above — never "0.0 MB". */
65
+ const formatSize = (bytes: number) => {
66
+ if (bytes < MB) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
67
+ const mb = bytes / MB;
68
+ return `${mb >= 10 ? mb.toFixed(0) : mb.toFixed(1)} MB`;
69
+ };
70
+
71
+ const extensionOf = (name: string) => {
72
+ const i = name.lastIndexOf(".");
73
+ return i < 0 || i >= name.length - 1 ? "" : name.slice(i + 1).toLowerCase();
74
+ };
75
+
76
+ /** "until 14:32" in the viewer's clock, so the link's lifetime is concrete. */
77
+ const untilLabel = (iso: string) => {
78
+ const d = new Date(iso);
79
+ if (Number.isNaN(d.getTime())) return "";
80
+ return `until ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
81
+ };
82
+
83
+ // --- rendering -----------------------------------------------------------------
84
+
85
+ function showOnly(id: "drop" | "progress" | "done" | "expired" | null) {
86
+ for (const key of ["drop", "progress", "done", "expired"] as const) $(key).hidden = key !== id;
87
+ }
88
+
89
+ function showError(text: string, withPageFallback = false) {
90
+ $("error-text").textContent = text;
91
+ $("error-actions").hidden = !withPageFallback || !state.summary?.browserUploadUrl;
92
+ $("error").hidden = false;
93
+ }
94
+
95
+ function clearError() {
96
+ $("error").hidden = true;
97
+ }
98
+
99
+ function render() {
100
+ const s = state.summary;
101
+ if (!s) {
102
+ $("title").textContent = state.fileName ? `Send ${state.fileName} to Womp` : "Send your 3D model to Womp";
103
+ $("sub").textContent = "so it can be quoted and printed";
104
+ showOnly(null);
105
+ return;
106
+ }
107
+
108
+ $("title").textContent = `Send ${s.fileName} to Womp`;
109
+ $("sub").textContent = "so it can be quoted and printed";
110
+
111
+ switch (state.phase) {
112
+ case "pending": {
113
+ $("drop-text").textContent = `Drop ${s.fileName} here`;
114
+ const until = untilLabel(s.expiresAt);
115
+ $("drop-hint").textContent = `Only .${s.format} · up to ${s.maxMb} MB${until ? ` · link valid ${until}` : ""}`;
116
+ ($("file") as HTMLInputElement).accept = `.${s.format}`;
117
+ showOnly("drop");
118
+ break;
119
+ }
120
+ case "uploading": {
121
+ $("progress-name").textContent = `Uploading ${state.fileName || s.fileName}…`;
122
+ showOnly("progress");
123
+ break;
124
+ }
125
+ case "done": {
126
+ $("done-name").textContent = state.fileName || s.fileName;
127
+ const size = state.fileSize ?? s.fileSize;
128
+ $("done-size").textContent = size ? ` (${formatSize(size)})` : "";
129
+ $("sub").textContent = "ready to quote";
130
+ showOnly("done");
131
+ break;
132
+ }
133
+ case "expired": {
134
+ $("expired-name").textContent = s.fileName;
135
+ $("sub").textContent = "upload link expired";
136
+ showOnly("expired");
137
+ break;
138
+ }
139
+ default:
140
+ showOnly(null);
141
+ }
142
+ }
143
+
144
+ function setProgress(fraction: number) {
145
+ // Holds at 99% until the server answers — see the CSS note.
146
+ const pct = Math.min(99, Math.round(fraction * 100));
147
+ $("fill").style.width = `${pct}%`;
148
+ $("progress-pct").textContent = `${pct}%`;
149
+ }
150
+
151
+ // --- applying a summary ---------------------------------------------------------
152
+
153
+ function applySummary(s: UploadSummary) {
154
+ state.summary = s;
155
+ if (s.status === "ready") {
156
+ state.phase = "done";
157
+ stopPolling();
158
+ } else if (s.status === "expired") {
159
+ state.phase = "expired";
160
+ stopPolling();
161
+ } else if (state.phase !== "uploading") {
162
+ state.phase = "pending";
163
+ }
164
+ clearError();
165
+ if (s.status === "pending" && s.lastError && state.phase === "pending") {
166
+ // A PUT elsewhere (the agent's shell, a previous drop) was refused — say
167
+ // why, since that attempt's own response is not visible here.
168
+ showError(`A previous attempt was refused: ${s.lastError}`);
169
+ }
170
+ render();
171
+ setStatus("");
172
+ if (state.phase === "pending") schedulePoll(POLL_START_MS);
173
+ }
174
+
175
+ // --- polling get_upload while pending ------------------------------------------
176
+
177
+ function stopPolling() {
178
+ if (pollTimer !== undefined) {
179
+ clearTimeout(pollTimer);
180
+ pollTimer = undefined;
181
+ }
182
+ }
183
+
184
+ function schedulePoll(delay: number) {
185
+ stopPolling();
186
+ const s = state.summary;
187
+ if (!s || state.phase !== "pending") return;
188
+ // The ticket's own expiry bounds the loop; nothing to learn after it.
189
+ const expiresAt = new Date(s.expiresAt).getTime();
190
+ if (Number.isFinite(expiresAt) && Date.now() > expiresAt + 60_000) return;
191
+
192
+ pollTimer = window.setTimeout(async () => {
193
+ if (state.phase !== "pending") return;
194
+ try {
195
+ const res = await callTool(card.app, "get_upload", { uploadId: s.uploadId });
196
+ if (!res.isError && res.structuredContent) {
197
+ const next = res.structuredContent as unknown as UploadSummary;
198
+ if (next.status !== "pending" || next.lastError !== s.lastError) {
199
+ applySummary(next);
200
+ return;
201
+ }
202
+ }
203
+ } catch {
204
+ // A failed poll is not a failed upload; the expiry bound stops this.
205
+ }
206
+ schedulePoll(Math.min(delay * 1.5, POLL_MAX_MS));
207
+ }, delay);
208
+ }
209
+
210
+ // --- the upload itself ------------------------------------------------------------
211
+
212
+ /** Why this file cannot be sent for this ticket, in words, or null when it can. */
213
+ function localRefusal(file: File, s: UploadSummary): string | null {
214
+ const ext = extensionOf(file.name);
215
+ if (ext !== s.format) {
216
+ return `This upload expects a .${s.format} file (${s.fileName}); "${file.name}" is a .${ext || "?"}. Ask your assistant for a new upload if the format changed.`;
217
+ }
218
+ if (file.size > s.maxBytes) {
219
+ return `"${file.name}" is ${formatSize(file.size)}; this account's limit is ${s.maxMb} MB per file.`;
220
+ }
221
+ return null;
222
+ }
223
+
224
+ function messageFrom(body: string, status: number): string {
225
+ try {
226
+ const parsed = JSON.parse(body) as { error?: { message?: string } };
227
+ if (parsed?.error?.message) return parsed.error.message;
228
+ } catch {
229
+ // not JSON — a proxy page or nothing; fall through to the status
230
+ }
231
+ return status ? `Upload failed (${status}). Try again.` : "";
232
+ }
233
+
234
+ function upload(file: File) {
235
+ const s = state.summary;
236
+ if (!s || state.phase !== "pending") return;
237
+ clearError();
238
+ const refusal = localRefusal(file, s);
239
+ if (refusal) {
240
+ showError(refusal);
241
+ return;
242
+ }
243
+
244
+ stopPolling();
245
+ state.phase = "uploading";
246
+ state.fileName = file.name;
247
+ state.fileSize = file.size;
248
+ setProgress(0);
249
+ render();
250
+ setStatus("");
251
+
252
+ const xhr = new XMLHttpRequest();
253
+ xhr.open("PUT", s.uploadUrl);
254
+ xhr.setRequestHeader("Content-Type", "application/octet-stream");
255
+ xhr.upload.onprogress = (e) => {
256
+ if (e.lengthComputable) setProgress(e.loaded / e.total);
257
+ };
258
+ xhr.onload = () => {
259
+ if (xhr.status >= 200 && xhr.status < 300) {
260
+ let stored: UploadSummary | undefined;
261
+ try {
262
+ stored = JSON.parse(xhr.responseText) as UploadSummary;
263
+ } catch {
264
+ // keep what we know; the row is ready either way
265
+ }
266
+ applySummary(stored ?? { ...s, status: "ready", fileSize: file.size });
267
+ return;
268
+ }
269
+ if (xhr.status === 409) {
270
+ // Someone else's upload (the agent's shell, another tab) won. Theirs is the file.
271
+ applySummary({ ...s, status: "ready" });
272
+ return;
273
+ }
274
+ if (xhr.status === 410) {
275
+ applySummary({ ...s, status: "expired" });
276
+ return;
277
+ }
278
+ state.phase = "pending";
279
+ render();
280
+ showError(messageFrom(xhr.responseText, xhr.status) || `Upload failed (${xhr.status}). Try again.`);
281
+ schedulePoll(POLL_START_MS);
282
+ };
283
+ const networkFailure = () => {
284
+ // No response at all: the host's sandbox refused the request (CSP or
285
+ // CORS), or the network dropped. The womp.com page does the same job.
286
+ state.phase = "pending";
287
+ render();
288
+ showError("Could not send the file from inside the chat. The upload page does the same thing in a tab.", true);
289
+ setStatus("PUT to the upload URL got no response — check connectDomains / CORS if this repeats.", true);
290
+ schedulePoll(POLL_START_MS);
291
+ };
292
+ xhr.onerror = networkFailure;
293
+ xhr.onabort = networkFailure;
294
+ xhr.send(file);
295
+ }
296
+
297
+ // --- wiring ---------------------------------------------------------------------
298
+
299
+ const card = createCard("Womp upload", {
300
+ onInput: (args) => {
301
+ // The host announces the call before any result: name the file straight away.
302
+ if (typeof args.fileName === "string") state.fileName = args.fileName;
303
+ render();
304
+ setStatus("Preparing upload…");
305
+ },
306
+ onResult: (raw: GenerationResult) => {
307
+ const data = raw as unknown as Partial<UploadSummary> & GenerationResult;
308
+ if (data.status === "running") {
309
+ render();
310
+ setStatus("Preparing upload…");
311
+ return;
312
+ }
313
+ if (typeof data.uploadId !== "number" || !data.uploadUrl) {
314
+ setStatus("The upload result is missing its URL — is swan-backend on a build with create_upload?", true);
315
+ return;
316
+ }
317
+ applySummary(data as UploadSummary);
318
+ },
319
+ });
320
+
321
+ const drop = $("drop");
322
+ const input = $("file") as HTMLInputElement;
323
+
324
+ $("choose").addEventListener("click", () => input.click());
325
+ input.addEventListener("change", () => {
326
+ const file = input.files?.[0];
327
+ input.value = "";
328
+ if (file) upload(file);
329
+ });
330
+
331
+ for (const type of ["dragenter", "dragover"]) {
332
+ drop.addEventListener(type, (e) => {
333
+ e.preventDefault();
334
+ drop.classList.add("active");
335
+ });
336
+ }
337
+ for (const type of ["dragleave", "drop"]) {
338
+ drop.addEventListener(type, (e) => {
339
+ e.preventDefault();
340
+ drop.classList.remove("active");
341
+ });
342
+ }
343
+ drop.addEventListener("drop", (e) => {
344
+ const file = (e as DragEvent).dataTransfer?.files?.[0];
345
+ if (file) upload(file);
346
+ });
347
+
348
+ $("open-page").addEventListener("click", () => {
349
+ const url = state.summary?.browserUploadUrl;
350
+ if (url) void openLink(card.app, url);
351
+ });