@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.
- package/dist/assets/DropZone.css +1 -1
- package/dist/components/preact/DropZone/DropZone.d.ts +29 -7
- package/dist/components/preact/DropZone/DropZone.js +101 -128
- package/dist/components/preact/DropZone/useDropDeploy.d.ts +50 -0
- package/dist/components/preact/DropZone/useDropDeploy.js +64 -0
- package/package.json +1 -1
- package/packages/components/preact/DropZone/DropZone.css +52 -91
- package/packages/components/preact/DropZone/DropZone.tsx +153 -159
- package/packages/components/preact/DropZone/useDropDeploy.ts +149 -0
package/dist/assets/DropZone.css
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
3
|
-
import I from "
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
57
|
-
"data-state":
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
46
|
+
onDrop: (r) => {
|
|
47
|
+
if (r.preventDefault(), T(), o) return;
|
|
48
|
+
const t = r.dataTransfer;
|
|
49
|
+
t && b(() => I(t), "drag");
|
|
63
50
|
},
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
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(
|
|
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(
|
|
108
|
+
return /* @__PURE__ */ e(c, { label: "Reading files…" });
|
|
111
109
|
case "creating":
|
|
112
|
-
return /* @__PURE__ */ e(
|
|
110
|
+
return /* @__PURE__ */ e(c, { label: "Creating deploy…" });
|
|
113
111
|
case "processing":
|
|
114
|
-
return /* @__PURE__ */ e(
|
|
112
|
+
return /* @__PURE__ */ e(c, { label: "Processing — almost live…" });
|
|
115
113
|
case "redirecting":
|
|
116
|
-
return /* @__PURE__ */ e(
|
|
114
|
+
return /* @__PURE__ */ e(c, { label: "Site’s live — taking you to claim it…" });
|
|
117
115
|
case "uploading": {
|
|
118
|
-
const
|
|
119
|
-
return /* @__PURE__ */ e(
|
|
120
|
-
/* @__PURE__ */ e(
|
|
121
|
-
/* @__PURE__ */ e("div", { class: "n-dropzone-progress", role: "progressbar", "aria-valuenow":
|
|
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(
|
|
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:
|
|
126
|
+
/* @__PURE__ */ e("button", { type: "button", class: "n-dropzone-browse", onClick: i, children: "Try again" })
|
|
129
127
|
] });
|
|
130
128
|
}
|
|
131
129
|
}
|
|
132
|
-
function
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
/*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
/*
|
|
60
|
+
/* Overlay (drag tint / status) */
|
|
66
61
|
/* ----------------------------- */
|
|
67
|
-
.n-dropzone-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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-
|
|
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
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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:
|
|
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 & 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
|
+
}
|