@netlify/spark-ui 1.26.0-alpha.6 → 1.26.0-alpha.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.
@@ -1,29 +1,18 @@
1
- import { useRef, useState } from 'preact/hooks';
2
- import Icon from '../Icon';
3
- import {
4
- AnonymousDropClient,
5
- deployFiles,
6
- getSingleNonIndexHtmlFileName,
7
- hasMultipleHtmlFilesWithoutIndex,
8
- readDataTransfer,
9
- readFileList,
10
- type DeployProgress,
11
- type DropClient,
12
- type ProcessedFile,
13
- } from './drop-core';
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import type { ComponentChildren } from 'preact';
3
+ import { readDataTransfer, readFileList, type DropClient } from './drop-core';
4
+ import { useDropDeploy, type DeployedSite, type DeployStatus, type TrackFn } from './useDropDeploy';
14
5
 
15
6
  import './DropZone.css';
16
7
 
17
- type Status =
18
- | { kind: 'idle' }
19
- | { kind: 'reading' }
20
- | { kind: 'creating' }
21
- | { kind: 'uploading'; uploaded: number; total: number }
22
- | { kind: 'processing' }
23
- | { kind: 'redirecting' }
24
- | { kind: 'error'; message: string };
8
+ /** How long after the last `dragover` event we consider the drag gone. */
9
+ const DRAG_INACTIVE_MS = 120;
10
+
11
+ const defaultClaimUrl = (siteName: string) => `https://app.netlify.com/drop/${siteName}`;
25
12
 
26
13
  interface Props {
14
+ /** Content the drop layer sits on top of. The zone is invisible until you drag over it. */
15
+ children?: ComponentChildren;
27
16
  /** Override the API base (e.g. for a staging environment). */
28
17
  apiBase?: string;
29
18
  /** Inject a custom client (e.g. for testing). Defaults to AnonymousDropClient. */
@@ -31,160 +20,189 @@ interface Props {
31
20
  /** Build the "claim this site" URL from the assigned site name (subdomain). */
32
21
  claimUrl?: (siteName: string) => string;
33
22
  /** Called once the deploy is live. */
34
- onDeploy?: (result: { url: string; token: string; siteName: string; siteId: string }) => void;
23
+ onDeploy?: (result: DeployedSite) => void;
24
+ /**
25
+ * Render the built-in status overlay (drag tint, spinner, progress bar, error).
26
+ * Set to `false` to suppress it entirely and keep your children visible, then
27
+ * drive your own feedback from the `data-state` attribute on the root element.
28
+ * Note: in this mode you're responsible for surfacing errors yourself.
29
+ */
30
+ showStatus?: boolean;
31
+ /**
32
+ * Analytics hook. Called at each tracked moment with an event name and
33
+ * properties. Stays provider-agnostic — wire it to Segment/Amplitude/GA in the
34
+ * consuming app, e.g. `onTrack={(name, props) => new Analytics().track(name, props)}`.
35
+ * Emitted events (all include `dropzone_id` when `analyticsId` is set):
36
+ * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count })
37
+ * - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
38
+ * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count })
39
+ */
40
+ onTrack?: TrackFn;
41
+ /**
42
+ * Stable identifier for this instance, attached to every tracked event as
43
+ * `dropzone_id`. Set it when a page renders more than one Drop Zone so the
44
+ * events can be told apart in Amplitude.
45
+ */
46
+ analyticsId?: string;
35
47
  className?: string;
36
48
  }
37
49
 
38
- const defaultClaimUrl = (siteName: string) => `https://app.netlify.com/drop/${siteName}`;
39
-
40
- function progressPhaseToStatus(e: DeployProgress): Status {
41
- switch (e.phase) {
42
- case 'creating':
43
- return { kind: 'creating' };
44
- case 'uploading':
45
- return { kind: 'uploading', uploaded: e.uploaded ?? 0, total: e.total ?? 0 };
46
- case 'processing':
47
- case 'ready':
48
- return { kind: 'processing' };
49
- }
50
- }
51
-
52
50
  export default function DropZone({
51
+ children,
53
52
  apiBase,
54
53
  client,
55
54
  claimUrl = defaultClaimUrl,
56
55
  onDeploy,
56
+ showStatus = true,
57
+ onTrack,
58
+ analyticsId,
57
59
  className,
58
60
  }: Props) {
59
- const [status, setStatus] = useState<Status>({ kind: 'idle' });
60
- const [warning, setWarning] = useState<string | null>(null);
61
- const [active, setActive] = useState(false);
62
- const dropClient = useRef(client ?? new AnonymousDropClient(apiBase)).current;
63
-
64
- const busy = status.kind !== 'idle' && status.kind !== 'error';
65
-
66
- async function run(read: () => Promise<ProcessedFile[]>) {
67
- setWarning(null);
68
- setStatus({ kind: 'reading' });
69
- try {
70
- const files = await read();
71
- if (!files.length) {
72
- setStatus({ kind: 'error', message: 'No files found to deploy.' });
73
- return;
74
- }
75
-
76
- // Surface index.html warnings without blocking the deploy.
77
- const singleNonIndex = getSingleNonIndexHtmlFileName(files);
78
- if (singleNonIndex) {
79
- setWarning(`No index.html found — "${singleNonIndex}" won’t be served at the site root.`);
80
- } else if (hasMultipleHtmlFilesWithoutIndex(files)) {
81
- setWarning('No index.html found — your site may not have a landing page.');
82
- }
83
-
84
- const { url, token, deploy } = await deployFiles(dropClient, files, {
85
- onProgress: e => setStatus(progressPhaseToStatus(e)),
86
- });
87
-
88
- // Best-effort local persistence. Note this does NOT reach the dashboard:
89
- // localStorage is per-origin, so a token written here (e.g. www.netlify.com)
90
- // is invisible to app.netlify.com. The cross-origin handoff is the URL
91
- // fragment below; this only helps if the drop and claim share an origin.
92
- try {
93
- localStorage.setItem('drop-token', token);
94
- localStorage.setItem('dropSiteName', deploy.subdomain);
95
- } catch {
96
- /* private mode / storage disabled — non-fatal */
97
- }
98
-
99
- setStatus({ kind: 'redirecting' });
100
- onDeploy?.({ url, token, siteName: deploy.subdomain, siteId: deploy.id });
101
-
102
- // Hand the drop token to the dashboard via the URL fragment — localStorage
103
- // does not cross origins. The dashboard's /drop/:name page reads
104
- // `#drop_token=` and persists it for the claim + post-signup steps.
105
- window.location.assign(`${claimUrl(deploy.subdomain)}#drop_token=${token}`);
106
- } catch (err) {
107
- setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
108
- }
109
- }
110
-
111
- function reset() {
112
- setWarning(null);
113
- setStatus({ kind: 'idle' });
114
- }
61
+ // Tag every event with this instance's id so multiple zones on a page can be
62
+ // told apart, then hand it off to the consumer's analytics.
63
+ const track: TrackFn = (event, properties = {}) =>
64
+ onTrack?.(event, analyticsId ? { dropzone_id: analyticsId, ...properties } : properties);
65
+
66
+ const { status, warning, busy, deploy, reset } = useDropDeploy({
67
+ apiBase,
68
+ client,
69
+ claimUrl,
70
+ onDeploy,
71
+ track,
72
+ });
73
+
74
+ const { active, keepActive, endDrag } = useDragActive();
75
+
76
+ const inputRef = useRef<HTMLInputElement>(null);
77
+ const openPicker = (method: 'keyboard' | 'click') => {
78
+ track('dropzone_browse_opened', { method });
79
+ inputRef.current?.click();
80
+ };
81
+
82
+ // The overlay only paints when there's something to show — dragging, deploying,
83
+ // or an error. With `showStatus` off, the consumer drives their own feedback.
84
+ const showOverlay = showStatus && (active || busy || status.kind === 'error');
85
+ const rootClass = [
86
+ 'n-dropzone',
87
+ active && 'is-active',
88
+ busy && 'is-busy',
89
+ !showStatus && 'is-bare',
90
+ className,
91
+ ]
92
+ .filter(Boolean)
93
+ .join(' ');
115
94
 
116
95
  return (
117
96
  <div
118
- class={`n-dropzone${active ? ' is-active' : ''}${busy ? ' is-busy' : ''}${className ? ` ${className}` : ''}`}
97
+ class={rootClass}
119
98
  data-state={status.kind}
99
+ role="button"
100
+ tabIndex={busy ? -1 : 0}
101
+ aria-label="Drop a site folder or zip here to deploy it, or press Enter to browse for files"
102
+ aria-busy={busy}
120
103
  onDragOver={e => {
121
104
  if (busy) return;
122
105
  e.preventDefault();
123
- setActive(true);
124
- }}
125
- onDragLeave={e => {
126
- // Ignore drag-leave bubbling from children.
127
- if (e.currentTarget === e.target) setActive(false);
106
+ keepActive();
128
107
  }}
129
108
  onDrop={e => {
130
109
  e.preventDefault();
131
- setActive(false);
110
+ endDrag();
132
111
  if (busy) return;
133
112
  const dt = e.dataTransfer;
134
113
  if (!dt) return;
135
114
  // Capture entries synchronously — readDataTransfer reads them before
136
115
  // its first await, so the DataTransfer isn't detached out from under us.
137
- run(() => readDataTransfer(dt));
116
+ deploy(() => readDataTransfer(dt), 'drag');
117
+ }}
118
+ onClick={e => {
119
+ // Only the zone's own empty area opens the picker; clicks that land on
120
+ // children are left to those children so they stay interactive.
121
+ if (!busy && e.target === e.currentTarget) openPicker('click');
122
+ }}
123
+ onKeyDown={e => {
124
+ if (busy) return;
125
+ if (e.key === 'Enter' || e.key === ' ') {
126
+ e.preventDefault();
127
+ openPicker('keyboard');
128
+ }
138
129
  }}
139
130
  >
140
- {/* keyed so each state change remounts and replays its enter animation */}
141
- <div class="n-dropzone-panel" key={status.kind}>
142
- <StatusView status={status} onReset={reset} onFiles={run} />
143
- </div>
144
-
145
- {warning && status.kind !== 'error' && (
146
- <p class="n-dropzone-warning n-fadeIn" role="status">
147
- <Icon name="lightbulb" className="n-dropzone-warning-icon" />
148
- {warning}
149
- </p>
131
+ <div class="n-dropzone-content">{children}</div>
132
+
133
+ {/* Invisible-but-present fallback: keyboard/click opens the file picker. */}
134
+ <input
135
+ ref={inputRef}
136
+ type="file"
137
+ hidden
138
+ multiple
139
+ // @ts-expect-error non-standard directory-picker attributes
140
+ webkitdirectory=""
141
+ directory=""
142
+ onChange={e => {
143
+ const input = e.currentTarget as HTMLInputElement;
144
+ if (input.files?.length) deploy(() => readFileList(input.files!), 'browse');
145
+ input.value = '';
146
+ }}
147
+ />
148
+
149
+ {showOverlay && (
150
+ <div
151
+ class="n-dropzone-overlay n-fadeIn"
152
+ key={status.kind === 'idle' ? 'active' : status.kind}
153
+ >
154
+ <StatusView status={status} active={active} onReset={reset} />
155
+ {warning && status.kind !== 'error' && busy && (
156
+ <p class="n-dropzone-warning" role="status">
157
+ {warning}
158
+ </p>
159
+ )}
160
+ </div>
150
161
  )}
151
162
  </div>
152
163
  );
153
164
  }
154
165
 
166
+ /**
167
+ * Track whether a drag is currently over the zone. A drag has no reliable
168
+ * "cancelled" event — pressing Escape or dropping elsewhere just stops the
169
+ * stream of `dragover` events — so we stay active only while they keep arriving
170
+ * and clear shortly after they stop. This also handles drags over children,
171
+ * where `dragleave` targets a child rather than the zone.
172
+ */
173
+ function useDragActive() {
174
+ const [active, setActive] = useState(false);
175
+ const timer = useRef<ReturnType<typeof setTimeout>>();
176
+
177
+ useEffect(() => () => clearTimeout(timer.current), []);
178
+
179
+ const keepActive = () => {
180
+ setActive(true);
181
+ clearTimeout(timer.current);
182
+ timer.current = setTimeout(() => setActive(false), DRAG_INACTIVE_MS);
183
+ };
184
+
185
+ const endDrag = () => {
186
+ clearTimeout(timer.current);
187
+ setActive(false);
188
+ };
189
+
190
+ return { active, keepActive, endDrag };
191
+ }
192
+
155
193
  function StatusView({
156
194
  status,
195
+ active,
157
196
  onReset,
158
- onFiles,
159
197
  }: {
160
- status: Status;
198
+ status: DeployStatus;
199
+ active: boolean;
161
200
  onReset: () => void;
162
- onFiles: (read: () => Promise<ProcessedFile[]>) => void;
163
201
  }) {
164
202
  switch (status.kind) {
203
+ // Idle-but-active means a drag is hovering the zone.
165
204
  case 'idle':
166
- return (
167
- <>
168
- <UploadGlyph />
169
- <p class="n-dropzone-title">Drag &amp; drop your site folder or zip</p>
170
- <p class="n-dropzone-subtitle">Deploys instantly to a live URL — no account needed.</p>
171
- <label class="n-dropzone-browse">
172
- Browse files
173
- <input
174
- type="file"
175
- hidden
176
- multiple
177
- // @ts-expect-error non-standard directory-picker attributes
178
- webkitdirectory=""
179
- directory=""
180
- onChange={e => {
181
- const input = e.currentTarget as HTMLInputElement;
182
- if (input.files?.length) onFiles(() => readFileList(input.files!));
183
- }}
184
- />
185
- </label>
186
- </>
187
- );
205
+ return active ? <p class="n-dropzone-hint">Drop to deploy</p> : null;
188
206
 
189
207
  case 'reading':
190
208
  return <Spinner label="Reading files…" />;
@@ -232,27 +250,3 @@ function Spinner({ label }: { label: string }) {
232
250
  </div>
233
251
  );
234
252
  }
235
-
236
- function UploadGlyph() {
237
- return (
238
- <span class="n-dropzone-glyph" aria-hidden="true">
239
- <svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
240
- <path
241
- class="n-dropzone-glyph-tray"
242
- d="M8 30v6a4 4 0 0 0 4 4h24a4 4 0 0 0 4-4v-6"
243
- stroke="currentColor"
244
- stroke-width="3"
245
- stroke-linecap="round"
246
- />
247
- <path
248
- class="n-dropzone-glyph-arrow"
249
- d="M24 32V10m0 0-8 8m8-8 8 8"
250
- stroke="currentColor"
251
- stroke-width="3"
252
- stroke-linecap="round"
253
- stroke-linejoin="round"
254
- />
255
- </svg>
256
- </span>
257
- );
258
- }
@@ -0,0 +1,149 @@
1
+ import { useRef, useState } from 'preact/hooks';
2
+ import {
3
+ AnonymousDropClient,
4
+ deployFiles,
5
+ getSingleNonIndexHtmlFileName,
6
+ hasMultipleHtmlFilesWithoutIndex,
7
+ type DeployProgress,
8
+ type DropClient,
9
+ type ProcessedFile,
10
+ } from './drop-core';
11
+
12
+ /** How the files reached the zone — carried through to analytics. */
13
+ export type DeploySource = 'drag' | 'browse';
14
+
15
+ /** The live site handed back once a deploy succeeds. */
16
+ export interface DeployedSite {
17
+ url: string;
18
+ token: string;
19
+ siteName: string;
20
+ siteId: string;
21
+ }
22
+
23
+ /** Finite states of a single deploy attempt. */
24
+ export type DeployStatus =
25
+ | { kind: 'idle' }
26
+ | { kind: 'reading' }
27
+ | { kind: 'creating' }
28
+ | { kind: 'uploading'; uploaded: number; total: number }
29
+ | { kind: 'processing' }
30
+ | { kind: 'redirecting' }
31
+ | { kind: 'error'; message: string };
32
+
33
+ /** Provider-agnostic analytics sink. */
34
+ export type TrackFn = (event: string, properties?: Record<string, unknown>) => void;
35
+
36
+ interface UseDropDeployOptions {
37
+ apiBase?: string;
38
+ client?: DropClient;
39
+ claimUrl: (siteName: string) => string;
40
+ onDeploy?: (site: DeployedSite) => void;
41
+ track: TrackFn;
42
+ }
43
+
44
+ /** Map a low-level deploy progress event onto our UI status. */
45
+ function progressToStatus(e: DeployProgress): DeployStatus {
46
+ switch (e.phase) {
47
+ case 'creating':
48
+ return { kind: 'creating' };
49
+ case 'uploading':
50
+ return { kind: 'uploading', uploaded: e.uploaded ?? 0, total: e.total ?? 0 };
51
+ case 'processing':
52
+ case 'ready':
53
+ return { kind: 'processing' };
54
+ }
55
+ }
56
+
57
+ /** Inspect the file set and return a non-blocking index.html warning, if any. */
58
+ function indexHtmlWarning(files: ProcessedFile[]): string | null {
59
+ const singleNonIndex = getSingleNonIndexHtmlFileName(files);
60
+ if (singleNonIndex) {
61
+ return `No index.html found — "${singleNonIndex}" won’t be served at the site root.`;
62
+ }
63
+ if (hasMultipleHtmlFilesWithoutIndex(files)) {
64
+ return 'No index.html found — your site may not have a landing page.';
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Owns the deploy state machine and its side effects (upload, local persistence,
71
+ * analytics, redirect), keeping DropZone a thin view over the returned `status`.
72
+ */
73
+ export function useDropDeploy({
74
+ apiBase,
75
+ client,
76
+ claimUrl,
77
+ onDeploy,
78
+ track,
79
+ }: UseDropDeployOptions) {
80
+ const [status, setStatus] = useState<DeployStatus>({ kind: 'idle' });
81
+ const [warning, setWarning] = useState<string | null>(null);
82
+ const dropClient = useRef(client ?? new AnonymousDropClient(apiBase)).current;
83
+
84
+ const busy = status.kind !== 'idle' && status.kind !== 'error';
85
+
86
+ async function deploy(read: () => Promise<ProcessedFile[]>, source: DeploySource) {
87
+ setWarning(null);
88
+ setStatus({ kind: 'reading' });
89
+ try {
90
+ const files = await read();
91
+ if (!files.length) {
92
+ setStatus({ kind: 'error', message: 'No files found to deploy.' });
93
+ return;
94
+ }
95
+
96
+ // A drop is the "user provided files" signal; the browse path is already
97
+ // tracked when the picker opens.
98
+ if (source === 'drag') {
99
+ track('dropzone_files_dropped', { method: source, file_count: files.length });
100
+ }
101
+
102
+ setWarning(indexHtmlWarning(files));
103
+
104
+ const {
105
+ url,
106
+ token,
107
+ deploy: result,
108
+ } = await deployFiles(dropClient, files, {
109
+ onProgress: e => setStatus(progressToStatus(e)),
110
+ });
111
+
112
+ // Best-effort local persistence. Note this does NOT reach the dashboard:
113
+ // localStorage is per-origin, so a token written here (e.g. www.netlify.com)
114
+ // is invisible to app.netlify.com. The cross-origin handoff is the URL
115
+ // fragment below; this only helps if the drop and claim share an origin.
116
+ try {
117
+ localStorage.setItem('drop-token', token);
118
+ localStorage.setItem('dropSiteName', result.subdomain);
119
+ } catch {
120
+ /* private mode / storage disabled — non-fatal */
121
+ }
122
+
123
+ setStatus({ kind: 'redirecting' });
124
+ // Fired before the redirect below. Note: the navigation can cut a
125
+ // fire-and-forget analytics request short — see the docs for delivery
126
+ // hardening (sendBeacon / deferring the redirect).
127
+ track('dropzone_deploy_succeeded', {
128
+ method: source,
129
+ site_name: result.subdomain,
130
+ file_count: files.length,
131
+ });
132
+ onDeploy?.({ url, token, siteName: result.subdomain, siteId: result.id });
133
+
134
+ // Hand the drop token to the dashboard via the URL fragment — localStorage
135
+ // does not cross origins. The dashboard's /drop/:name page reads
136
+ // `#drop_token=` and persists it for the claim + post-signup steps.
137
+ window.location.assign(`${claimUrl(result.subdomain)}#drop_token=${token}`);
138
+ } catch (err) {
139
+ setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
140
+ }
141
+ }
142
+
143
+ function reset() {
144
+ setWarning(null);
145
+ setStatus({ kind: 'idle' });
146
+ }
147
+
148
+ return { status, warning, busy, deploy, reset };
149
+ }