@netlify/spark-ui 1.26.0-alpha.6 → 1.26.0-alpha.7

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 +1 @@
1
- .n-fadeIn{opacity:0;animation:fadeIn var(--animation-duration, .3s) ease-out forwards}.n-fadeInUp{opacity:0;transform:translateY(var(--animation-transformY, 5px));animation:fadeInUp var(--animation-duration, .3s) ease-out forwards}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes fadeInUp{to{opacity:1;transform:translateY(0)}}.n-dropzone{--n-dropzone-accent: var(--color-brand-primary, #0e7fe1);--n-dropzone-radius: var(--radius-2xl, 1rem);box-sizing:border-box;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--space-s);width:100%;height:100%;min-height:18rem;padding:var(--space-2xl, 2.5rem) var(--space-l, 1.5rem);text-align:center;color:var(--color-text-default, #14161a);background:var(--color-bg, #fff);border:2px dashed var(--color-border-default, rgba(0, 0, 0, .15));border-radius:var(--n-dropzone-radius);transition:border-color .2s ease-out,background-color .2s ease-out,box-shadow .2s ease-out}.n-dropzone.is-active{border-color:var(--n-dropzone-accent);background:var(--color-bg-2, #f6f8fa);box-shadow:0 0 0 4px color-mix(in srgb,var(--n-dropzone-accent) 18%,transparent)}.n-dropzone.is-busy{border-style:solid;border-color:var(--color-border-subtle, rgba(0, 0, 0, .08))}.n-dropzone:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;border-radius:inherit;background:radial-gradient(120% 120% at 50% 0%,color-mix(in srgb,var(--n-dropzone-accent) 8%,transparent),transparent 60%);opacity:0;transition:opacity .2s ease-out;pointer-events:none}.n-dropzone.is-active:before{opacity:1}.n-dropzone-panel{display:flex;flex-direction:column;align-items:center;gap:var(--space-xs, .75rem);width:100%;animation:dropPanelIn .32s cubic-bezier(.22,1,.36,1) both}@keyframes dropPanelIn{0%{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}.n-dropzone-title{margin:0;font-size:1.125rem;font-weight:var(--font-weight-semibold, 600);line-height:var(--line-height-snug, 1.3)}.n-dropzone-title--error{color:var(--color-invalid-text, #d00)}.n-dropzone-subtitle{margin:0;max-width:30rem;font-size:.9375rem;color:var(--color-text-weak, #5c6570);line-height:var(--line-height-text, 1.5)}.n-dropzone-glyph{display:grid;place-items:center;width:3.5rem;height:3.5rem;color:var(--n-dropzone-accent)}.n-dropzone-glyph svg{width:100%;height:100%}.n-dropzone-glyph-arrow{animation:dropArrowBob 2.4s ease-in-out infinite}.n-dropzone.is-active .n-dropzone-glyph-arrow{animation-duration:.9s}@keyframes dropArrowBob{0%,to{transform:translateY(0)}50%{transform:translateY(-3px)}}.n-dropzone-loading{display:flex;flex-direction:column;align-items:center;gap:var(--space-s, 1rem)}.n-dropzone-spinner{width:2.25rem;height:2.25rem;border-radius:var(--radius-pill, 999px);border:3px solid color-mix(in srgb,var(--n-dropzone-accent) 22%,transparent);border-top-color:var(--n-dropzone-accent);animation:dropSpin .72s linear infinite}@keyframes dropSpin{to{transform:rotate(360deg)}}.n-dropzone-nudge{display:block;font-size:.8125rem;color:var(--color-text-weaker, #8a929c)}.n-dropzone-progress{width:min(22rem,100%);height:.5rem;border-radius:var(--radius-pill, 999px);background:var(--color-bg-3, rgba(0, 0, 0, .08));overflow:hidden}.n-dropzone-progress-bar{height:100%;border-radius:inherit;background:var(--n-dropzone-accent);transition:width .3s cubic-bezier(.22,1,.36,1)}.n-dropzone-browse{display:inline-flex;align-items:center;justify-content:center;padding:var(--space-2xs, .5rem) var(--space-m, 1.25rem);border:none;border-radius:var(--radius-pill, 999px);background:var(--n-dropzone-accent);color:var(--neutral-000, #fff);font-size:.9375rem;font-weight:var(--font-weight-semibold, 600);line-height:var(--line-height-none, 1);text-decoration:none;cursor:pointer;transition:background-color .15s ease-out,transform .12s ease-out}.n-dropzone-browse:hover{background:var(--color-brand-primary-hover, #0b6bc0);transform:translateY(-1px)}.n-dropzone-browse:active{transform:translateY(0)}.n-dropzone-warning{display:flex;align-items:center;gap:var(--space-2xs, .5rem);margin:0;margin-block-start:var(--space-xs, .75rem);padding:var(--space-2xs, .5rem) var(--space-s, 1rem);max-width:32rem;border-radius:var(--radius-m, .5rem);background:var(--color-warning-bg, #fdf3d7);color:var(--color-warning-text, #7a5b00);font-size:.8125rem;text-align:left}.n-dropzone-warning-icon{flex:none;width:1rem;height:1rem}@media (prefers-reduced-motion: reduce){.n-dropzone,.n-dropzone-panel,.n-dropzone-glyph-arrow,.n-dropzone-spinner,.n-dropzone-progress-bar{animation:none!important;transition:none!important}.n-dropzone-spinner{border-top-color:var(--n-dropzone-accent)}}
1
+ .n-fadeIn{opacity:0;animation:fadeIn var(--animation-duration, .3s) ease-out forwards}.n-fadeInUp{opacity:0;transform:translateY(var(--animation-transformY, 5px));animation:fadeInUp var(--animation-duration, .3s) ease-out forwards}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes fadeInUp{to{opacity:1;transform:translateY(0)}}.n-dropzone{--n-dropzone-accent: var(--color-brand-primary, #0e7fe1);--n-dropzone-radius: var(--radius-2xl, 1rem);box-sizing:border-box;position:relative;min-height:4rem;color:var(--color-text-default, #14161a);border:2px dashed var(--color-border-default, rgba(0, 0, 0, .15));border-radius:var(--n-dropzone-radius);outline:none;transition:border-color .2s ease-out,background-color .2s ease-out,box-shadow .2s ease-out}.n-dropzone:focus-visible{border-color:var(--n-dropzone-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--n-dropzone-accent) 40%,transparent)}.n-dropzone.is-active{border-color:var(--n-dropzone-accent);box-shadow:0 0 0 4px color-mix(in srgb,var(--n-dropzone-accent) 18%,transparent)}.n-dropzone.is-busy{border-style:solid;border-color:var(--color-border-subtle, rgba(0, 0, 0, .08))}.n-dropzone-content{transition:opacity .15s ease-out}.n-dropzone.is-active:not(.is-bare) .n-dropzone-content,.n-dropzone.is-busy:not(.is-bare) .n-dropzone-content{opacity:0;pointer-events:none}.n-dropzone-overlay{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--space-s, 1rem);padding:var(--space-l, 1.5rem);text-align:center;border-radius:inherit;pointer-events:none;background:color-mix(in srgb,var(--n-dropzone-accent) 10%,transparent)}.n-dropzone.is-busy .n-dropzone-overlay,.n-dropzone[data-state=error] .n-dropzone-overlay{pointer-events:auto;background:color-mix(in srgb,var(--color-bg, #fff) 88%,transparent);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px)}.n-dropzone-hint{margin:0;font-size:1.0625rem;font-weight:var(--font-weight-semibold, 600);color:var(--n-dropzone-accent)}.n-dropzone-title{margin:0;font-size:1.125rem;font-weight:var(--font-weight-semibold, 600);line-height:var(--line-height-snug, 1.3)}.n-dropzone-title--error{color:var(--color-invalid-text, #d00)}.n-dropzone-subtitle{margin:0;max-width:30rem;font-size:.9375rem;color:var(--color-text-weak, #5c6570);line-height:var(--line-height-text, 1.5)}.n-dropzone-loading{display:flex;flex-direction:column;align-items:center;gap:var(--space-s, 1rem)}.n-dropzone-spinner{width:2.25rem;height:2.25rem;border-radius:var(--radius-pill, 999px);border:3px solid color-mix(in srgb,var(--n-dropzone-accent) 22%,transparent);border-top-color:var(--n-dropzone-accent);animation:dropSpin .72s linear infinite}@keyframes dropSpin{to{transform:rotate(360deg)}}.n-dropzone-nudge{display:block;font-size:.8125rem;color:var(--color-text-weaker, #8a929c)}.n-dropzone-progress{width:min(22rem,100%);height:.5rem;border-radius:var(--radius-pill, 999px);background:var(--color-bg-3, rgba(0, 0, 0, .08));overflow:hidden}.n-dropzone-progress-bar{height:100%;border-radius:inherit;background:var(--n-dropzone-accent);transition:width .3s cubic-bezier(.22,1,.36,1)}.n-dropzone-browse{display:inline-flex;align-items:center;justify-content:center;padding:var(--space-2xs, .5rem) var(--space-m, 1.25rem);border:none;border-radius:var(--radius-pill, 999px);background:var(--n-dropzone-accent);color:var(--neutral-000, #fff);font-size:.9375rem;font-weight:var(--font-weight-semibold, 600);line-height:var(--line-height-none, 1);text-decoration:none;cursor:pointer;transition:background-color .15s ease-out,transform .12s ease-out}.n-dropzone-browse:hover{background:var(--color-brand-primary-hover, #0b6bc0);transform:translateY(-1px)}.n-dropzone-browse:active{transform:translateY(0)}.n-dropzone-warning{margin:0;padding:var(--space-2xs, .5rem) var(--space-s, 1rem);max-width:32rem;border-radius:var(--radius-m, .5rem);background:var(--color-warning-bg, #fdf3d7);color:var(--color-warning-text, #7a5b00);font-size:.8125rem}@media (prefers-reduced-motion: reduce){.n-dropzone,.n-dropzone-overlay,.n-dropzone-spinner,.n-dropzone-progress-bar{animation:none!important;transition:none!important}.n-dropzone-spinner{border-top-color:var(--n-dropzone-accent)}}
@@ -1,5 +1,9 @@
1
+ import { ComponentChildren } from 'preact';
1
2
  import { DropClient } from './drop-core';
3
+ import { DeployedSite, TrackFn } from './useDropDeploy';
2
4
  interface Props {
5
+ /** Content the drop layer sits on top of. The zone is invisible until you drag over it. */
6
+ children?: ComponentChildren;
3
7
  /** Override the API base (e.g. for a staging environment). */
4
8
  apiBase?: string;
5
9
  /** Inject a custom client (e.g. for testing). Defaults to AnonymousDropClient. */
@@ -7,13 +11,31 @@ interface Props {
7
11
  /** Build the "claim this site" URL from the assigned site name (subdomain). */
8
12
  claimUrl?: (siteName: string) => string;
9
13
  /** Called once the deploy is live. */
10
- onDeploy?: (result: {
11
- url: string;
12
- token: string;
13
- siteName: string;
14
- siteId: string;
15
- }) => void;
14
+ onDeploy?: (result: DeployedSite) => void;
15
+ /**
16
+ * Render the built-in status overlay (drag tint, spinner, progress bar, error).
17
+ * Set to `false` to suppress it entirely and keep your children visible, then
18
+ * drive your own feedback from the `data-state` attribute on the root element.
19
+ * Note: in this mode you're responsible for surfacing errors yourself.
20
+ */
21
+ showStatus?: boolean;
22
+ /**
23
+ * Analytics hook. Called at each tracked moment with an event name and
24
+ * properties. Stays provider-agnostic — wire it to Segment/Amplitude/GA in the
25
+ * consuming app, e.g. `onTrack={(name, props) => new Analytics().track(name, props)}`.
26
+ * Emitted events (all include `dropzone_id` when `analyticsId` is set):
27
+ * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count })
28
+ * - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
29
+ * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count })
30
+ */
31
+ onTrack?: TrackFn;
32
+ /**
33
+ * Stable identifier for this instance, attached to every tracked event as
34
+ * `dropzone_id`. Set it when a page renders more than one Drop Zone so the
35
+ * events can be told apart in Amplitude.
36
+ */
37
+ analyticsId?: string;
16
38
  className?: string;
17
39
  }
18
- export default function DropZone({ apiBase, client, claimUrl, onDeploy, className, }: Props): import("preact").JSX.Element;
40
+ export default function DropZone({ children, apiBase, client, claimUrl, onDeploy, showStatus, onTrack, analyticsId, className, }: Props): import("preact").JSX.Element;
19
41
  export {};
@@ -1,135 +1,133 @@
1
1
  import { u as e } from "../../../chunks/jsxRuntime.module.Bzuv3cXw.js";
2
- import { d as m, A as C } from "../../../chunks/hooks.module.DRcxknIz.js";
3
- import I from "../Icon/index.js";
4
- import { getSingleNonIndexHtmlFileName as N, hasMultipleHtmlFilesWithoutIndex as D, readDataTransfer as F, readFileList as T } from "./drop-core/readFiles.js";
5
- import { AnonymousDropClient as M } from "./drop-core/client.js";
6
- import { deployFiles as P } from "./drop-core/deploy.js";
7
- import { Fragment as k } from "preact";
8
- import '../../../assets/DropZone.css';const U = (n) => `https://app.netlify.com/drop/${n}`;
9
- function A(n) {
10
- switch (n.phase) {
11
- case "creating":
12
- return { kind: "creating" };
13
- case "uploading":
14
- return { kind: "uploading", uploaded: n.uploaded ?? 0, total: n.total ?? 0 };
15
- case "processing":
16
- case "ready":
17
- return { kind: "processing" };
18
- }
19
- }
20
- function O({
21
- apiBase: n,
22
- client: p,
23
- claimUrl: u = U,
24
- onDeploy: i,
25
- className: s
2
+ import { A as k, d as S, y as $ } from "../../../chunks/hooks.module.DRcxknIz.js";
3
+ import { readFileList as E, readDataTransfer as I } from "./drop-core/readFiles.js";
4
+ import { useDropDeploy as P } from "./useDropDeploy.js";
5
+ import { Fragment as D } from "preact";
6
+ import '../../../assets/DropZone.css';const x = 120, F = (n) => `https://app.netlify.com/drop/${n}`;
7
+ function K({
8
+ children: n,
9
+ apiBase: l,
10
+ client: i,
11
+ claimUrl: s = F,
12
+ onDeploy: u,
13
+ showStatus: f = !0,
14
+ onTrack: d,
15
+ analyticsId: g,
16
+ className: w
26
17
  }) {
27
- const [o, a] = m({ kind: "idle" }), [w, d] = m(null), [v, g] = m(!1), z = C(p ?? new M(n)).current, h = o.kind !== "idle" && o.kind !== "error";
28
- async function y(t) {
29
- d(null), a({ kind: "reading" });
30
- try {
31
- const r = await t();
32
- if (!r.length) {
33
- a({ kind: "error", message: "No files found to deploy." });
34
- return;
35
- }
36
- const b = N(r);
37
- b ? d(`No index.html found — "${b}" won’t be served at the site root.`) : D(r) && d("No index.html found — your site may not have a landing page.");
38
- const { url: $, token: f, deploy: c } = await P(z, r, {
39
- onProgress: (x) => a(A(x))
40
- });
41
- try {
42
- localStorage.setItem("drop-token", f), localStorage.setItem("dropSiteName", c.subdomain);
43
- } catch {
44
- }
45
- a({ kind: "redirecting" }), i == null || i({ url: $, token: f, siteName: c.subdomain, siteId: c.id }), window.location.assign(`${u(c.subdomain)}#drop_token=${f}`);
46
- } catch (r) {
47
- a({ kind: "error", message: r instanceof Error ? r.message : String(r) });
48
- }
49
- }
50
- function S() {
51
- d(null), a({ kind: "idle" });
52
- }
18
+ const v = (r, t = {}) => d == null ? void 0 : d(r, g ? { dropzone_id: g, ...t } : t), { status: a, warning: h, busy: o, deploy: b, reset: A } = P({
19
+ apiBase: l,
20
+ client: i,
21
+ claimUrl: s,
22
+ onDeploy: u,
23
+ track: v
24
+ }), { active: p, keepActive: C, endDrag: T } = M(), m = k(null), y = (r) => {
25
+ var t;
26
+ v("dropzone_browse_opened", { method: r }), (t = m.current) == null || t.click();
27
+ }, _ = f && (p || o || a.kind === "error"), R = [
28
+ "n-dropzone",
29
+ p && "is-active",
30
+ o && "is-busy",
31
+ !f && "is-bare",
32
+ w
33
+ ].filter(Boolean).join(" ");
53
34
  return /* @__PURE__ */ e(
54
35
  "div",
55
36
  {
56
- class: `n-dropzone${v ? " is-active" : ""}${h ? " is-busy" : ""}${s ? ` ${s}` : ""}`,
57
- "data-state": o.kind,
58
- onDragOver: (t) => {
59
- h || (t.preventDefault(), g(!0));
37
+ class: R,
38
+ "data-state": a.kind,
39
+ role: "button",
40
+ tabIndex: o ? -1 : 0,
41
+ "aria-label": "Drop a site folder or zip here to deploy it, or press Enter to browse for files",
42
+ "aria-busy": o,
43
+ onDragOver: (r) => {
44
+ o || (r.preventDefault(), C());
60
45
  },
61
- onDragLeave: (t) => {
62
- t.currentTarget === t.target && g(!1);
46
+ onDrop: (r) => {
47
+ if (r.preventDefault(), T(), o) return;
48
+ const t = r.dataTransfer;
49
+ t && b(() => I(t), "drag");
63
50
  },
64
- onDrop: (t) => {
65
- if (t.preventDefault(), g(!1), h) return;
66
- const r = t.dataTransfer;
67
- r && y(() => F(r));
51
+ onClick: (r) => {
52
+ !o && r.target === r.currentTarget && y("click");
53
+ },
54
+ onKeyDown: (r) => {
55
+ o || (r.key === "Enter" || r.key === " ") && (r.preventDefault(), y("keyboard"));
68
56
  },
69
57
  children: [
70
- /* @__PURE__ */ e("div", { class: "n-dropzone-panel", children: /* @__PURE__ */ e(L, { status: o, onReset: S, onFiles: y }) }, o.kind),
71
- w && o.kind !== "error" && /* @__PURE__ */ e("p", { class: "n-dropzone-warning n-fadeIn", role: "status", children: [
72
- /* @__PURE__ */ e(I, { name: "lightbulb", className: "n-dropzone-warning-icon" }),
73
- w
74
- ] })
58
+ /* @__PURE__ */ e("div", { class: "n-dropzone-content", children: n }),
59
+ /* @__PURE__ */ e(
60
+ "input",
61
+ {
62
+ ref: m,
63
+ type: "file",
64
+ hidden: !0,
65
+ multiple: !0,
66
+ webkitdirectory: "",
67
+ directory: "",
68
+ onChange: (r) => {
69
+ var z;
70
+ const t = r.currentTarget;
71
+ (z = t.files) != null && z.length && b(() => E(t.files), "browse"), t.value = "";
72
+ }
73
+ }
74
+ ),
75
+ _ && /* @__PURE__ */ e(
76
+ "div",
77
+ {
78
+ class: "n-dropzone-overlay n-fadeIn",
79
+ children: [
80
+ /* @__PURE__ */ e(O, { status: a, active: p, onReset: A }),
81
+ h && a.kind !== "error" && o && /* @__PURE__ */ e("p", { class: "n-dropzone-warning", role: "status", children: h })
82
+ ]
83
+ },
84
+ a.kind === "idle" ? "active" : a.kind
85
+ )
75
86
  ]
76
87
  }
77
88
  );
78
89
  }
79
- function L({
90
+ function M() {
91
+ const [n, l] = S(!1), i = k();
92
+ return $(() => () => clearTimeout(i.current), []), { active: n, keepActive: () => {
93
+ l(!0), clearTimeout(i.current), i.current = setTimeout(() => l(!1), x);
94
+ }, endDrag: () => {
95
+ clearTimeout(i.current), l(!1);
96
+ } };
97
+ }
98
+ function O({
80
99
  status: n,
81
- onReset: p,
82
- onFiles: u
100
+ active: l,
101
+ onReset: i
83
102
  }) {
84
103
  switch (n.kind) {
104
+ // Idle-but-active means a drag is hovering the zone.
85
105
  case "idle":
86
- return /* @__PURE__ */ e(k, { children: [
87
- /* @__PURE__ */ e(R, {}),
88
- /* @__PURE__ */ e("p", { class: "n-dropzone-title", children: "Drag & drop your site folder or zip" }),
89
- /* @__PURE__ */ e("p", { class: "n-dropzone-subtitle", children: "Deploys instantly to a live URL — no account needed." }),
90
- /* @__PURE__ */ e("label", { class: "n-dropzone-browse", children: [
91
- "Browse files",
92
- /* @__PURE__ */ e(
93
- "input",
94
- {
95
- type: "file",
96
- hidden: !0,
97
- multiple: !0,
98
- webkitdirectory: "",
99
- directory: "",
100
- onChange: (i) => {
101
- var o;
102
- const s = i.currentTarget;
103
- (o = s.files) != null && o.length && u(() => T(s.files));
104
- }
105
- }
106
- )
107
- ] })
108
- ] });
106
+ return l ? /* @__PURE__ */ e("p", { class: "n-dropzone-hint", children: "Drop to deploy" }) : null;
109
107
  case "reading":
110
- return /* @__PURE__ */ e(l, { label: "Reading files…" });
108
+ return /* @__PURE__ */ e(c, { label: "Reading files…" });
111
109
  case "creating":
112
- return /* @__PURE__ */ e(l, { label: "Creating deploy…" });
110
+ return /* @__PURE__ */ e(c, { label: "Creating deploy…" });
113
111
  case "processing":
114
- return /* @__PURE__ */ e(l, { label: "Processing — almost live…" });
112
+ return /* @__PURE__ */ e(c, { label: "Processing — almost live…" });
115
113
  case "redirecting":
116
- return /* @__PURE__ */ e(l, { label: "Site’s live — taking you to claim it…" });
114
+ return /* @__PURE__ */ e(c, { label: "Site’s live — taking you to claim it…" });
117
115
  case "uploading": {
118
- const i = n.total ? Math.round(n.uploaded / n.total * 100) : 0;
119
- return /* @__PURE__ */ e(k, { children: [
120
- /* @__PURE__ */ e(l, { label: `Uploading ${n.uploaded}/${n.total} files…` }),
121
- /* @__PURE__ */ e("div", { class: "n-dropzone-progress", role: "progressbar", "aria-valuenow": i, children: /* @__PURE__ */ e("div", { class: "n-dropzone-progress-bar", style: { width: `${i}%` } }) })
116
+ const s = n.total ? Math.round(n.uploaded / n.total * 100) : 0;
117
+ return /* @__PURE__ */ e(D, { children: [
118
+ /* @__PURE__ */ e(c, { label: `Uploading ${n.uploaded}/${n.total} files…` }),
119
+ /* @__PURE__ */ e("div", { class: "n-dropzone-progress", role: "progressbar", "aria-valuenow": s, children: /* @__PURE__ */ e("div", { class: "n-dropzone-progress-bar", style: { width: `${s}%` } }) })
122
120
  ] });
123
121
  }
124
122
  case "error":
125
- return /* @__PURE__ */ e(k, { children: [
123
+ return /* @__PURE__ */ e(D, { children: [
126
124
  /* @__PURE__ */ e("p", { class: "n-dropzone-title n-dropzone-title--error", children: "Something went wrong" }),
127
125
  /* @__PURE__ */ e("p", { class: "n-dropzone-subtitle", role: "alert", children: n.message }),
128
- /* @__PURE__ */ e("button", { type: "button", class: "n-dropzone-browse", onClick: p, children: "Try again" })
126
+ /* @__PURE__ */ e("button", { type: "button", class: "n-dropzone-browse", onClick: i, children: "Try again" })
129
127
  ] });
130
128
  }
131
129
  }
132
- function l({ label: n }) {
130
+ function c({ label: n }) {
133
131
  return /* @__PURE__ */ e("div", { class: "n-dropzone-loading", children: [
134
132
  /* @__PURE__ */ e("span", { class: "n-dropzone-spinner", "aria-hidden": "true" }),
135
133
  /* @__PURE__ */ e("p", { class: "n-dropzone-subtitle", "aria-live": "polite", children: [
@@ -139,31 +137,6 @@ function l({ label: n }) {
139
137
  ] })
140
138
  ] });
141
139
  }
142
- function R() {
143
- return /* @__PURE__ */ e("span", { class: "n-dropzone-glyph", "aria-hidden": "true", children: /* @__PURE__ */ e("svg", { viewBox: "0 0 48 48", fill: "none", xmlns: "http://www.w3.org/2000/svg", children: [
144
- /* @__PURE__ */ e(
145
- "path",
146
- {
147
- class: "n-dropzone-glyph-tray",
148
- d: "M8 30v6a4 4 0 0 0 4 4h24a4 4 0 0 0 4-4v-6",
149
- stroke: "currentColor",
150
- "stroke-width": "3",
151
- "stroke-linecap": "round"
152
- }
153
- ),
154
- /* @__PURE__ */ e(
155
- "path",
156
- {
157
- class: "n-dropzone-glyph-arrow",
158
- d: "M24 32V10m0 0-8 8m8-8 8 8",
159
- stroke: "currentColor",
160
- "stroke-width": "3",
161
- "stroke-linecap": "round",
162
- "stroke-linejoin": "round"
163
- }
164
- )
165
- ] }) });
166
- }
167
140
  export {
168
- O as default
141
+ K as default
169
142
  };
@@ -0,0 +1,50 @@
1
+ import { DropClient, ProcessedFile } from './drop-core';
2
+ /** How the files reached the zone — carried through to analytics. */
3
+ export type DeploySource = 'drag' | 'browse';
4
+ /** The live site handed back once a deploy succeeds. */
5
+ export interface DeployedSite {
6
+ url: string;
7
+ token: string;
8
+ siteName: string;
9
+ siteId: string;
10
+ }
11
+ /** Finite states of a single deploy attempt. */
12
+ export type DeployStatus = {
13
+ kind: 'idle';
14
+ } | {
15
+ kind: 'reading';
16
+ } | {
17
+ kind: 'creating';
18
+ } | {
19
+ kind: 'uploading';
20
+ uploaded: number;
21
+ total: number;
22
+ } | {
23
+ kind: 'processing';
24
+ } | {
25
+ kind: 'redirecting';
26
+ } | {
27
+ kind: 'error';
28
+ message: string;
29
+ };
30
+ /** Provider-agnostic analytics sink. */
31
+ export type TrackFn = (event: string, properties?: Record<string, unknown>) => void;
32
+ interface UseDropDeployOptions {
33
+ apiBase?: string;
34
+ client?: DropClient;
35
+ claimUrl: (siteName: string) => string;
36
+ onDeploy?: (site: DeployedSite) => void;
37
+ track: TrackFn;
38
+ }
39
+ /**
40
+ * Owns the deploy state machine and its side effects (upload, local persistence,
41
+ * analytics, redirect), keeping DropZone a thin view over the returned `status`.
42
+ */
43
+ export declare function useDropDeploy({ apiBase, client, claimUrl, onDeploy, track, }: UseDropDeployOptions): {
44
+ status: DeployStatus;
45
+ warning: string | null;
46
+ busy: boolean;
47
+ deploy: (read: () => Promise<ProcessedFile[]>, source: DeploySource) => Promise<void>;
48
+ reset: () => void;
49
+ };
50
+ export {};
@@ -0,0 +1,64 @@
1
+ import { d as c, A as _ } from "../../../chunks/hooks.module.DRcxknIz.js";
2
+ import { getSingleNonIndexHtmlFileName as x, hasMultipleHtmlFilesWithoutIndex as S } from "./drop-core/readFiles.js";
3
+ import { AnonymousDropClient as b } from "./drop-core/client.js";
4
+ import { deployFiles as I } from "./drop-core/deploy.js";
5
+ function F(n) {
6
+ switch (n.phase) {
7
+ case "creating":
8
+ return { kind: "creating" };
9
+ case "uploading":
10
+ return { kind: "uploading", uploaded: n.uploaded ?? 0, total: n.total ?? 0 };
11
+ case "processing":
12
+ case "ready":
13
+ return { kind: "processing" };
14
+ }
15
+ }
16
+ function H(n) {
17
+ const i = x(n);
18
+ return i ? `No index.html found — "${i}" won’t be served at the site root.` : S(n) ? "No index.html found — your site may not have a landing page." : null;
19
+ }
20
+ function A({
21
+ apiBase: n,
22
+ client: i,
23
+ claimUrl: m,
24
+ onDeploy: r,
25
+ track: u
26
+ }) {
27
+ const [d, t] = c({ kind: "idle" }), [g, s] = c(null), p = _(i ?? new b(n)).current, f = d.kind !== "idle" && d.kind !== "error";
28
+ async function h(y, a) {
29
+ s(null), t({ kind: "reading" });
30
+ try {
31
+ const e = await y();
32
+ if (!e.length) {
33
+ t({ kind: "error", message: "No files found to deploy." });
34
+ return;
35
+ }
36
+ a === "drag" && u("dropzone_files_dropped", { method: a, file_count: e.length }), s(H(e));
37
+ const {
38
+ url: w,
39
+ token: l,
40
+ deploy: o
41
+ } = await I(p, e, {
42
+ onProgress: (N) => t(F(N))
43
+ });
44
+ try {
45
+ localStorage.setItem("drop-token", l), localStorage.setItem("dropSiteName", o.subdomain);
46
+ } catch {
47
+ }
48
+ t({ kind: "redirecting" }), u("dropzone_deploy_succeeded", {
49
+ method: a,
50
+ site_name: o.subdomain,
51
+ file_count: e.length
52
+ }), r == null || r({ url: w, token: l, siteName: o.subdomain, siteId: o.id }), window.location.assign(`${m(o.subdomain)}#drop_token=${l}`);
53
+ } catch (e) {
54
+ t({ kind: "error", message: e instanceof Error ? e.message : String(e) });
55
+ }
56
+ }
57
+ function k() {
58
+ s(null), t({ kind: "idle" });
59
+ }
60
+ return { status: d, warning: g, busy: f, deploy: h, reset: k };
61
+ }
62
+ export {
63
+ A as useDropDeploy
64
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@netlify/spark-ui",
3
3
  "description": "Assets, design tokens, components, and utilities",
4
- "version": "1.26.0-alpha.6",
4
+ "version": "1.26.0-alpha.7",
5
5
  "type": "module",
6
6
  "main": "dist/components/preact/index.js",
7
7
  "types": "dist/components/preact/index.d.ts",
@@ -1,87 +1,95 @@
1
1
  @import url('../../../styles/animations.css');
2
2
 
3
3
  /* ----------------------------- */
4
- /* Container */
4
+ /* Container (invisible layer) */
5
5
  /* ----------------------------- */
6
+ /* The zone wraps arbitrary content and stays out of the way until a drag
7
+ enters it. Only a subtle dashed border hints that it's a drop target. */
6
8
  .n-dropzone {
7
9
  --n-dropzone-accent: var(--color-brand-primary, #0e7fe1);
8
10
  --n-dropzone-radius: var(--radius-2xl, 1rem);
9
11
 
10
12
  box-sizing: border-box;
11
13
  position: relative;
12
- display: flex;
13
- flex-direction: column;
14
- align-items: center;
15
- justify-content: center;
16
- gap: var(--space-s);
17
- width: 100%;
18
- height: 100%;
19
- min-height: 18rem;
20
- padding: var(--space-2xl, 2.5rem) var(--space-l, 1.5rem);
21
- text-align: center;
14
+ min-height: 4rem;
22
15
  color: var(--color-text-default, #14161a);
23
- background: var(--color-bg, #fff);
24
16
  border: 2px dashed var(--color-border-default, rgba(0, 0, 0, 0.15));
25
17
  border-radius: var(--n-dropzone-radius);
18
+ outline: none;
26
19
  transition:
27
20
  border-color 200ms ease-out,
28
21
  background-color 200ms ease-out,
29
22
  box-shadow 200ms ease-out;
30
23
  }
31
24
 
25
+ .n-dropzone:focus-visible {
26
+ border-color: var(--n-dropzone-accent);
27
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--n-dropzone-accent) 40%, transparent);
28
+ }
29
+
32
30
  /* Drag-over: the target glows without resizing. */
33
31
  .n-dropzone.is-active {
34
32
  border-color: var(--n-dropzone-accent);
35
- background: var(--color-bg-2, #f6f8fa);
36
33
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--n-dropzone-accent) 18%, transparent);
37
34
  }
38
35
 
36
+ /* While deploying, the dashes settle into a solid, quieter frame. */
39
37
  .n-dropzone.is-busy {
40
38
  border-style: solid;
41
39
  border-color: var(--color-border-subtle, rgba(0, 0, 0, 0.08));
42
40
  }
43
41
 
44
- /* A soft accent wash that fades in while dragging. */
45
- .n-dropzone::before {
46
- content: '';
47
- position: absolute;
48
- inset: 0;
49
- border-radius: inherit;
50
- background: radial-gradient(
51
- 120% 120% at 50% 0%,
52
- color-mix(in srgb, var(--n-dropzone-accent) 8%, transparent),
53
- transparent 60%
54
- );
55
- opacity: 0;
56
- transition: opacity 200ms ease-out;
57
- pointer-events: none;
42
+ /* ----------------------------- */
43
+ /* Wrapped content */
44
+ /* ----------------------------- */
45
+ /* Kept in flow (so the zone doesn't collapse) but faded out of the way once a
46
+ drag enters or a deploy starts, letting the overlay take over. */
47
+ .n-dropzone-content {
48
+ transition: opacity 150ms ease-out;
58
49
  }
59
50
 
60
- .n-dropzone.is-active::before {
61
- opacity: 1;
51
+ /* `.is-bare` (showStatus={false}) keeps content visible so a custom animation
52
+ can play in place of the built-in overlay. */
53
+ .n-dropzone.is-active:not(.is-bare) .n-dropzone-content,
54
+ .n-dropzone.is-busy:not(.is-bare) .n-dropzone-content {
55
+ opacity: 0;
56
+ pointer-events: none;
62
57
  }
63
58
 
64
59
  /* ----------------------------- */
65
- /* State panel (keyed → replays) */
60
+ /* Overlay (drag tint / status) */
66
61
  /* ----------------------------- */
67
- .n-dropzone-panel {
62
+ .n-dropzone-overlay {
63
+ position: absolute;
64
+ inset: 0;
65
+ z-index: 1;
68
66
  display: flex;
69
67
  flex-direction: column;
70
68
  align-items: center;
71
- gap: var(--space-xs, 0.75rem);
72
- width: 100%;
73
- animation: dropPanelIn 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
69
+ justify-content: center;
70
+ gap: var(--space-s, 1rem);
71
+ padding: var(--space-l, 1.5rem);
72
+ text-align: center;
73
+ border-radius: inherit;
74
+ /* During a hover-only drag the overlay is a translucent wash and lets the
75
+ drop event fall through to the container beneath it. */
76
+ pointer-events: none;
77
+ background: color-mix(in srgb, var(--n-dropzone-accent) 10%, transparent);
74
78
  }
75
79
 
76
- @keyframes dropPanelIn {
77
- from {
78
- opacity: 0;
79
- transform: translateY(8px) scale(0.98);
80
- }
81
- to {
82
- opacity: 1;
83
- transform: none;
84
- }
80
+ /* Deploying / error: opaque scrim that captures interaction. */
81
+ .n-dropzone.is-busy .n-dropzone-overlay,
82
+ .n-dropzone[data-state='error'] .n-dropzone-overlay {
83
+ pointer-events: auto;
84
+ background: color-mix(in srgb, var(--color-bg, #fff) 88%, transparent);
85
+ backdrop-filter: blur(2px);
86
+ }
87
+
88
+ .n-dropzone-hint {
89
+ margin: 0;
90
+ font-size: 1.0625rem;
91
+ font-weight: var(--font-weight-semibold, 600);
92
+ color: var(--n-dropzone-accent);
85
93
  }
86
94
 
87
95
  .n-dropzone-title {
@@ -103,41 +111,6 @@
103
111
  line-height: var(--line-height-text, 1.5);
104
112
  }
105
113
 
106
- /* ----------------------------- */
107
- /* Idle upload glyph */
108
- /* ----------------------------- */
109
- .n-dropzone-glyph {
110
- display: grid;
111
- place-items: center;
112
- width: 3.5rem;
113
- height: 3.5rem;
114
- color: var(--n-dropzone-accent);
115
- }
116
-
117
- .n-dropzone-glyph svg {
118
- width: 100%;
119
- height: 100%;
120
- }
121
-
122
- /* The arrow gently bobs to invite a drop; the tray stays put. */
123
- .n-dropzone-glyph-arrow {
124
- animation: dropArrowBob 2.4s ease-in-out infinite;
125
- }
126
-
127
- .n-dropzone.is-active .n-dropzone-glyph-arrow {
128
- animation-duration: 0.9s;
129
- }
130
-
131
- @keyframes dropArrowBob {
132
- 0%,
133
- 100% {
134
- transform: translateY(0);
135
- }
136
- 50% {
137
- transform: translateY(-3px);
138
- }
139
- }
140
-
141
114
  /* ----------------------------- */
142
115
  /* Loading / spinner */
143
116
  /* ----------------------------- */
@@ -222,24 +195,13 @@
222
195
  /* Warning banner */
223
196
  /* ----------------------------- */
224
197
  .n-dropzone-warning {
225
- display: flex;
226
- align-items: center;
227
- gap: var(--space-2xs, 0.5rem);
228
198
  margin: 0;
229
- margin-block-start: var(--space-xs, 0.75rem);
230
199
  padding: var(--space-2xs, 0.5rem) var(--space-s, 1rem);
231
200
  max-width: 32rem;
232
201
  border-radius: var(--radius-m, 0.5rem);
233
202
  background: var(--color-warning-bg, #fdf3d7);
234
203
  color: var(--color-warning-text, #7a5b00);
235
204
  font-size: 0.8125rem;
236
- text-align: left;
237
- }
238
-
239
- .n-dropzone-warning-icon {
240
- flex: none;
241
- width: 1rem;
242
- height: 1rem;
243
205
  }
244
206
 
245
207
  /* ----------------------------- */
@@ -247,8 +209,7 @@
247
209
  /* ----------------------------- */
248
210
  @media (prefers-reduced-motion: reduce) {
249
211
  .n-dropzone,
250
- .n-dropzone-panel,
251
- .n-dropzone-glyph-arrow,
212
+ .n-dropzone-overlay,
252
213
  .n-dropzone-spinner,
253
214
  .n-dropzone-progress-bar {
254
215
  animation: none !important;
@@ -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
+ }