@guard-rail/core 0.1.0

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.
Files changed (111) hide show
  1. package/README.md +339 -0
  2. package/dist/adapters/supabase/auth.d.ts +9 -0
  3. package/dist/adapters/supabase/auth.js +57 -0
  4. package/dist/adapters/supabase/auth.js.map +1 -0
  5. package/dist/adapters/supabase/client.d.ts +28 -0
  6. package/dist/adapters/supabase/client.js +44 -0
  7. package/dist/adapters/supabase/client.js.map +1 -0
  8. package/dist/adapters/supabase/confirm.d.ts +19 -0
  9. package/dist/adapters/supabase/confirm.js +27 -0
  10. package/dist/adapters/supabase/confirm.js.map +1 -0
  11. package/dist/adapters/supabase/db.d.ts +9 -0
  12. package/dist/adapters/supabase/db.js +49 -0
  13. package/dist/adapters/supabase/db.js.map +1 -0
  14. package/dist/adapters/supabase/index.d.ts +25 -0
  15. package/dist/adapters/supabase/index.js +29 -0
  16. package/dist/adapters/supabase/index.js.map +1 -0
  17. package/dist/adapters/supabase/members.d.ts +9 -0
  18. package/dist/adapters/supabase/members.js +73 -0
  19. package/dist/adapters/supabase/members.js.map +1 -0
  20. package/dist/adapters/supabase/repeaters.d.ts +9 -0
  21. package/dist/adapters/supabase/repeaters.js +73 -0
  22. package/dist/adapters/supabase/repeaters.js.map +1 -0
  23. package/dist/adapters/supabase/storage.d.ts +18 -0
  24. package/dist/adapters/supabase/storage.js +93 -0
  25. package/dist/adapters/supabase/storage.js.map +1 -0
  26. package/dist/core/adapter-types.d.ts +212 -0
  27. package/dist/core/adapter-types.js +1 -0
  28. package/dist/core/adapter-types.js.map +1 -0
  29. package/dist/core/bailout.d.ts +9 -0
  30. package/dist/core/bailout.js +14 -0
  31. package/dist/core/bailout.js.map +1 -0
  32. package/dist/core/config.d.ts +27 -0
  33. package/dist/core/config.js +46 -0
  34. package/dist/core/config.js.map +1 -0
  35. package/dist/core/defs.d.ts +22 -0
  36. package/dist/core/defs.js +16 -0
  37. package/dist/core/defs.js.map +1 -0
  38. package/dist/core/types.d.ts +136 -0
  39. package/dist/core/types.js +1 -0
  40. package/dist/core/types.js.map +1 -0
  41. package/dist/index.d.ts +13 -0
  42. package/dist/index.js +43 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/react/Admin.d.ts +8 -0
  45. package/dist/react/Admin.js +116 -0
  46. package/dist/react/Admin.js.map +1 -0
  47. package/dist/react/GRail/client.d.ts +11 -0
  48. package/dist/react/GRail/client.js +242 -0
  49. package/dist/react/GRail/client.js.map +1 -0
  50. package/dist/react/GRail/index.d.ts +12 -0
  51. package/dist/react/GRail/index.js +19 -0
  52. package/dist/react/GRail/index.js.map +1 -0
  53. package/dist/react/GRail/render.d.ts +23 -0
  54. package/dist/react/GRail/render.js +67 -0
  55. package/dist/react/GRail/render.js.map +1 -0
  56. package/dist/react/Icons.d.ts +13 -0
  57. package/dist/react/Icons.js +68 -0
  58. package/dist/react/Icons.js.map +1 -0
  59. package/dist/react/MediaPicker.d.ts +14 -0
  60. package/dist/react/MediaPicker.js +172 -0
  61. package/dist/react/MediaPicker.js.map +1 -0
  62. package/dist/react/Provider.d.ts +11 -0
  63. package/dist/react/Provider.js +26 -0
  64. package/dist/react/Provider.js.map +1 -0
  65. package/dist/react/Repeater/client.d.ts +16 -0
  66. package/dist/react/Repeater/client.js +148 -0
  67. package/dist/react/Repeater/client.js.map +1 -0
  68. package/dist/react/Repeater/index.d.ts +35 -0
  69. package/dist/react/Repeater/index.js +22 -0
  70. package/dist/react/Repeater/index.js.map +1 -0
  71. package/dist/react/Syncer.d.ts +17 -0
  72. package/dist/react/Syncer.js +23 -0
  73. package/dist/react/Syncer.js.map +1 -0
  74. package/dist/react/Team.d.ts +7 -0
  75. package/dist/react/Team.js +190 -0
  76. package/dist/react/Team.js.map +1 -0
  77. package/dist/react/resizeImage.d.ts +25 -0
  78. package/dist/react/resizeImage.js +52 -0
  79. package/dist/react/resizeImage.js.map +1 -0
  80. package/dist/react/styles.d.ts +421 -0
  81. package/dist/react/styles.js +402 -0
  82. package/dist/react/styles.js.map +1 -0
  83. package/dist/react/useAnchorRect.d.ts +17 -0
  84. package/dist/react/useAnchorRect.js +32 -0
  85. package/dist/react/useAnchorRect.js.map +1 -0
  86. package/dist/server/actions.d.ts +41 -0
  87. package/dist/server/actions.js +214 -0
  88. package/dist/server/actions.js.map +1 -0
  89. package/dist/server/data.d.ts +10 -0
  90. package/dist/server/data.js +24 -0
  91. package/dist/server/data.js.map +1 -0
  92. package/dist/server/fieldData.d.ts +26 -0
  93. package/dist/server/fieldData.js +9 -0
  94. package/dist/server/fieldData.js.map +1 -0
  95. package/dist/server/loader.d.ts +6 -0
  96. package/dist/server/loader.js +53 -0
  97. package/dist/server/loader.js.map +1 -0
  98. package/dist/server/repeaterActions.d.ts +20 -0
  99. package/dist/server/repeaterActions.js +72 -0
  100. package/dist/server/repeaterActions.js.map +1 -0
  101. package/dist/server/repeaterData.d.ts +18 -0
  102. package/dist/server/repeaterData.js +27 -0
  103. package/dist/server/repeaterData.js.map +1 -0
  104. package/dist/server/session.d.ts +14 -0
  105. package/dist/server/session.js +37 -0
  106. package/dist/server/session.js.map +1 -0
  107. package/dist/server/wrapper.d.ts +12 -0
  108. package/dist/server/wrapper.js +42 -0
  109. package/dist/server/wrapper.js.map +1 -0
  110. package/package.json +58 -0
  111. package/supabase/migrations/20260911000000_guardrail_init.sql +288 -0
@@ -0,0 +1,190 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import React, { useCallback, useEffect, useState, useTransition } from "react";
4
+ import {
5
+ inviteMember,
6
+ listTeam,
7
+ removeMember,
8
+ updateMemberRole
9
+ } from "../server/actions";
10
+ import { useGRail } from "./Provider";
11
+ import { CloseIcon } from "./Icons";
12
+ import { S } from "./styles";
13
+ const ROLES = ["admin", "editor"];
14
+ const ANIMATION_CSS = `
15
+ @keyframes gr-fade-in { from { opacity: 0 } to { opacity: 1 } }
16
+ @keyframes gr-slide-in {
17
+ from { transform: translateX(100%) }
18
+ to { transform: translateX(0) }
19
+ }
20
+ [data-guardrail-scrim] { animation: gr-fade-in 160ms ease-out both }
21
+ [data-guardrail-team] { animation: gr-slide-in 220ms cubic-bezier(0.32, 0.72, 0, 1) both }
22
+ [data-guardrail-team] [data-gr-row] { animation: gr-fade-in 240ms ease-out both }
23
+ @keyframes gr-fade-out { from { opacity: 1 } to { opacity: 0 } }
24
+ @keyframes gr-slide-out {
25
+ from { transform: translateX(0) }
26
+ to { transform: translateX(100%) }
27
+ }
28
+ [data-guardrail-scrim][data-closing] { animation: gr-fade-out 180ms ease-in both }
29
+ [data-guardrail-team][data-closing] { animation: gr-slide-out 180ms cubic-bezier(0.4, 0, 1, 1) both }
30
+ @media (prefers-reduced-motion: reduce) {
31
+ [data-guardrail-scrim], [data-guardrail-team], [data-guardrail-team] [data-gr-row] {
32
+ animation: none
33
+ }
34
+ }
35
+ `;
36
+ function TeamDrawer({ onClose }) {
37
+ const { role, email: currentEmail } = useGRail();
38
+ const isAdmin = role === "admin";
39
+ const [members, setMembers] = useState([]);
40
+ const [loading, setLoading] = useState(true);
41
+ const [error, setError] = useState(null);
42
+ const [inviteEmail, setInviteEmail] = useState("");
43
+ const [inviteRole, setInviteRole] = useState("editor");
44
+ const [pending, startTransition] = useTransition();
45
+ const refresh = () => listTeam().then((rows) => {
46
+ setMembers(rows);
47
+ setLoading(false);
48
+ });
49
+ useEffect(() => {
50
+ void refresh();
51
+ }, []);
52
+ const run = (fn) => startTransition(async () => {
53
+ setError(null);
54
+ const res = await fn();
55
+ if (!res.success) setError(res.error ?? "Something went wrong.");
56
+ await refresh();
57
+ });
58
+ const [closing, setClosing] = useState(false);
59
+ const requestClose = useCallback(() => {
60
+ const reduced = typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
61
+ if (reduced) return onClose();
62
+ setClosing(true);
63
+ }, [onClose]);
64
+ useEffect(() => {
65
+ if (!closing) return;
66
+ const t = window.setTimeout(onClose, 400);
67
+ return () => window.clearTimeout(t);
68
+ }, [closing, onClose]);
69
+ useEffect(() => {
70
+ const onKey = (e) => e.key === "Escape" && requestClose();
71
+ window.addEventListener("keydown", onKey);
72
+ return () => window.removeEventListener("keydown", onKey);
73
+ }, [requestClose]);
74
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
75
+ /* @__PURE__ */ jsx("style", { children: ANIMATION_CSS }),
76
+ /* @__PURE__ */ jsx(
77
+ "div",
78
+ {
79
+ "data-guardrail": true,
80
+ "data-guardrail-scrim": true,
81
+ ...closing ? { "data-closing": "" } : {},
82
+ style: S.scrim,
83
+ onClick: requestClose
84
+ }
85
+ ),
86
+ /* @__PURE__ */ jsxs(
87
+ "aside",
88
+ {
89
+ "data-guardrail": true,
90
+ "data-guardrail-team": true,
91
+ ...closing ? { "data-closing": "" } : {},
92
+ onAnimationEnd: (e) => {
93
+ if (closing && e.target === e.currentTarget) onClose();
94
+ },
95
+ style: S.drawer,
96
+ role: "dialog",
97
+ "aria-label": "Team",
98
+ children: [
99
+ /* @__PURE__ */ jsxs("header", { style: S.drawerHead, children: [
100
+ /* @__PURE__ */ jsx("h2", { style: S.drawerTitle, children: "Team" }),
101
+ /* @__PURE__ */ jsx("button", { style: { ...S.iconBtn, color: "#6b7280" }, onClick: requestClose, "aria-label": "Close", children: /* @__PURE__ */ jsx(CloseIcon, {}) })
102
+ ] }),
103
+ /* @__PURE__ */ jsxs("div", { style: S.drawerBody, children: [
104
+ error && /* @__PURE__ */ jsx("div", { style: { ...S.error, marginBottom: 12 }, children: error }),
105
+ loading ? /* @__PURE__ */ jsx("p", { style: { fontSize: 13, color: "#6b7280" }, children: "Loading\u2026" }) : members.map((m) => /* @__PURE__ */ jsxs("div", { "data-gr-row": true, style: S.memberRow, children: [
106
+ /* @__PURE__ */ jsxs("span", { style: S.memberEmail, title: m.email, children: [
107
+ m.email,
108
+ m.email === currentEmail && " (you)"
109
+ ] }),
110
+ m.status === "pending" && /* @__PURE__ */ jsx("span", { style: S.badge, children: "Invited" }),
111
+ isAdmin ? /* @__PURE__ */ jsx(
112
+ "select",
113
+ {
114
+ style: S.select,
115
+ value: m.role,
116
+ disabled: pending,
117
+ onChange: (e) => run(
118
+ () => updateMemberRole(m.email, e.target.value)
119
+ ),
120
+ children: ROLES.map((r) => /* @__PURE__ */ jsx("option", { value: r, children: r }, r))
121
+ }
122
+ ) : /* @__PURE__ */ jsx("span", { style: { fontSize: 12, color: "#6b7280" }, children: m.role }),
123
+ isAdmin && /* @__PURE__ */ jsx(
124
+ "button",
125
+ {
126
+ style: { ...S.iconBtn, color: "#9ca3af", width: 22, height: 22 },
127
+ disabled: pending,
128
+ onClick: () => run(() => removeMember(m.email)),
129
+ "aria-label": `Remove ${m.email}`,
130
+ title: "Remove",
131
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
132
+ }
133
+ )
134
+ ] }, m.email)),
135
+ isAdmin && /* @__PURE__ */ jsxs(Fragment, { children: [
136
+ /* @__PURE__ */ jsx("p", { style: S.sectionLabel, children: "Invite someone" }),
137
+ /* @__PURE__ */ jsxs(
138
+ "form",
139
+ {
140
+ onSubmit: (e) => {
141
+ e.preventDefault();
142
+ run(async () => {
143
+ const res = await inviteMember(inviteEmail, inviteRole);
144
+ if (res.success) setInviteEmail("");
145
+ return res;
146
+ });
147
+ },
148
+ children: [
149
+ /* @__PURE__ */ jsx(
150
+ "input",
151
+ {
152
+ style: S.field,
153
+ type: "email",
154
+ placeholder: "name@company.com",
155
+ value: inviteEmail,
156
+ onChange: (e) => setInviteEmail(e.target.value),
157
+ required: true
158
+ }
159
+ ),
160
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8 }, children: [
161
+ /* @__PURE__ */ jsx(
162
+ "select",
163
+ {
164
+ style: { ...S.select, flex: 1 },
165
+ value: inviteRole,
166
+ onChange: (e) => setInviteRole(e.target.value),
167
+ children: ROLES.map((r) => /* @__PURE__ */ jsx("option", { value: r, children: r }, r))
168
+ }
169
+ ),
170
+ /* @__PURE__ */ jsx("button", { style: S.primaryBtn, type: "submit", disabled: pending, children: pending ? "Working\u2026" : "Invite" })
171
+ ] })
172
+ ]
173
+ }
174
+ ),
175
+ /* @__PURE__ */ jsxs("p", { style: { fontSize: 11, color: "#9ca3af", marginTop: 10 }, children: [
176
+ "Guardrail records the invite. Sending the email is your app's job \u2014 wire the ",
177
+ /* @__PURE__ */ jsx("code", { children: "onInvite" }),
178
+ " handler."
179
+ ] })
180
+ ] })
181
+ ] })
182
+ ]
183
+ }
184
+ )
185
+ ] });
186
+ }
187
+ export {
188
+ TeamDrawer
189
+ };
190
+ //# sourceMappingURL=Team.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/react/Team.tsx"],"sourcesContent":["\"use client\";\n\nimport React, { useCallback, useEffect, useState, useTransition } from \"react\";\nimport {\n inviteMember,\n listTeam,\n removeMember,\n updateMemberRole,\n} from \"../server/actions\";\nimport { useGRail } from \"./Provider\";\nimport { CloseIcon } from \"./Icons\";\nimport { S } from \"./styles\";\nimport type { GRMember, GRRole } from \"../core/types\";\n\nconst ROLES: GRRole[] = [\"admin\", \"editor\"];\n\n/**\n * Keyframes cannot be expressed as inline styles, so the drawer ships the few\n * it needs. Scoped to [data-guardrail-team] to stay clear of app styles, and\n * disabled under prefers-reduced-motion.\n */\nconst ANIMATION_CSS = `\n@keyframes gr-fade-in { from { opacity: 0 } to { opacity: 1 } }\n@keyframes gr-slide-in {\n from { transform: translateX(100%) }\n to { transform: translateX(0) }\n}\n[data-guardrail-scrim] { animation: gr-fade-in 160ms ease-out both }\n[data-guardrail-team] { animation: gr-slide-in 220ms cubic-bezier(0.32, 0.72, 0, 1) both }\n[data-guardrail-team] [data-gr-row] { animation: gr-fade-in 240ms ease-out both }\n@keyframes gr-fade-out { from { opacity: 1 } to { opacity: 0 } }\n@keyframes gr-slide-out {\n from { transform: translateX(0) }\n to { transform: translateX(100%) }\n}\n[data-guardrail-scrim][data-closing] { animation: gr-fade-out 180ms ease-in both }\n[data-guardrail-team][data-closing] { animation: gr-slide-out 180ms cubic-bezier(0.4, 0, 1, 1) both }\n@media (prefers-reduced-motion: reduce) {\n [data-guardrail-scrim], [data-guardrail-team], [data-guardrail-team] [data-gr-row] {\n animation: none\n }\n}\n`;\n\nexport function TeamDrawer({ onClose }: { onClose: () => void }) {\n const { role, email: currentEmail } = useGRail();\n const isAdmin = role === \"admin\";\n\n const [members, setMembers] = useState<GRMember[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [inviteEmail, setInviteEmail] = useState(\"\");\n const [inviteRole, setInviteRole] = useState<GRRole>(\"editor\");\n const [pending, startTransition] = useTransition();\n\n const refresh = () =>\n listTeam().then((rows) => {\n setMembers(rows);\n setLoading(false);\n });\n\n useEffect(() => {\n void refresh();\n }, []);\n\n // Server actions re-check the role; this only decides what to render.\n // Always refresh: a failure can still have changed the roster (an invite\n // that saved but whose email failed, for instance).\n const run = (fn: () => Promise<{ success: boolean; error?: string }>) =>\n startTransition(async () => {\n setError(null);\n const res = await fn();\n if (!res.success) setError(res.error ?? \"Something went wrong.\");\n await refresh();\n });\n\n /**\n * Unmounting on close skips the exit animation entirely. Stay mounted, play\n * it, and let the parent unmount once it finishes.\n */\n const [closing, setClosing] = useState(false);\n const requestClose = useCallback(() => {\n const reduced =\n typeof window !== \"undefined\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n if (reduced) return onClose(); // No animation, so no animationend to wait for.\n setClosing(true);\n }, [onClose]);\n\n useEffect(() => {\n if (!closing) return;\n // Safety net if animationend never arrives (tab hidden, styles stripped).\n const t = window.setTimeout(onClose, 400);\n return () => window.clearTimeout(t);\n }, [closing, onClose]);\n\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && requestClose();\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [requestClose]);\n\n return (\n <>\n <style>{ANIMATION_CSS}</style>\n <div\n data-guardrail\n data-guardrail-scrim\n {...(closing ? { \"data-closing\": \"\" } : {})}\n style={S.scrim}\n onClick={requestClose}\n />\n <aside\n data-guardrail\n data-guardrail-team\n {...(closing ? { \"data-closing\": \"\" } : {})}\n onAnimationEnd={(e) => {\n // Row fade-ins bubble up here too; only the drawer's own exit counts.\n if (closing && e.target === e.currentTarget) onClose();\n }}\n style={S.drawer}\n role=\"dialog\"\n aria-label=\"Team\"\n >\n <header style={S.drawerHead}>\n <h2 style={S.drawerTitle}>Team</h2>\n <button style={{ ...S.iconBtn, color: \"#6b7280\" }} onClick={requestClose} aria-label=\"Close\">\n <CloseIcon />\n </button>\n </header>\n\n <div style={S.drawerBody}>\n {error && <div style={{ ...S.error, marginBottom: 12 }}>{error}</div>}\n\n {loading ? (\n <p style={{ fontSize: 13, color: \"#6b7280\" }}>Loading…</p>\n ) : (\n members.map((m) => (\n <div key={m.email} data-gr-row style={S.memberRow}>\n <span style={S.memberEmail} title={m.email}>\n {m.email}\n {m.email === currentEmail && \" (you)\"}\n </span>\n\n {m.status === \"pending\" && <span style={S.badge}>Invited</span>}\n\n {isAdmin ? (\n <select\n style={S.select}\n value={m.role}\n disabled={pending}\n onChange={(e) =>\n run(() =>\n updateMemberRole(m.email, e.target.value as GRRole),\n )\n }\n >\n {ROLES.map((r) => (\n <option key={r} value={r}>\n {r}\n </option>\n ))}\n </select>\n ) : (\n <span style={{ fontSize: 12, color: \"#6b7280\" }}>{m.role}</span>\n )}\n\n {isAdmin && (\n <button\n style={{ ...S.iconBtn, color: \"#9ca3af\", width: 22, height: 22 }}\n disabled={pending}\n onClick={() => run(() => removeMember(m.email))}\n aria-label={`Remove ${m.email}`}\n title=\"Remove\"\n >\n <CloseIcon />\n </button>\n )}\n </div>\n ))\n )}\n\n {isAdmin && (\n <>\n <p style={S.sectionLabel}>Invite someone</p>\n <form\n onSubmit={(e) => {\n e.preventDefault();\n run(async () => {\n const res = await inviteMember(inviteEmail, inviteRole);\n if (res.success) setInviteEmail(\"\");\n return res;\n });\n }}\n >\n <input\n style={S.field}\n type=\"email\"\n placeholder=\"name@company.com\"\n value={inviteEmail}\n onChange={(e) => setInviteEmail(e.target.value)}\n required\n />\n <div style={{ display: \"flex\", gap: 8 }}>\n <select\n style={{ ...S.select, flex: 1 }}\n value={inviteRole}\n onChange={(e) => setInviteRole(e.target.value as GRRole)}\n >\n {ROLES.map((r) => (\n <option key={r} value={r}>\n {r}\n </option>\n ))}\n </select>\n <button style={S.primaryBtn} type=\"submit\" disabled={pending}>\n {pending ? \"Working…\" : \"Invite\"}\n </button>\n </div>\n </form>\n <p style={{ fontSize: 11, color: \"#9ca3af\", marginTop: 10 }}>\n Guardrail records the invite. Sending the email is your app&apos;s\n job — wire the <code>onInvite</code> handler.\n </p>\n </>\n )}\n </div>\n </aside>\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Longest edge an uploaded image is scaled down to — WordPress's default "big
3
+ * image" threshold. Enough for full-width heroes on high-density screens,
4
+ * while a 12-megapixel phone photo (~4000px) shrinks to a fraction of its size.
5
+ */
6
+ declare const MAX_IMAGE_EDGE = 2560;
7
+ interface PreparedImage {
8
+ file: File;
9
+ width?: number;
10
+ height?: number;
11
+ resized: boolean;
12
+ }
13
+ /**
14
+ * Scales an image down before upload, in the browser.
15
+ *
16
+ * This runs client-side because uploads go straight to storage and never pass
17
+ * through the server. That makes it a convenience rather than a guarantee: the
18
+ * server's 10 MB cap and the bucket's own limit remain the hard ceiling.
19
+ *
20
+ * Re-encoding also drops EXIF data, including GPS location from phone photos.
21
+ * Images already within the limit are uploaded untouched, never recompressed.
22
+ */
23
+ declare function prepareImage(file: File, maxEdge?: number): Promise<PreparedImage>;
24
+
25
+ export { MAX_IMAGE_EDGE, type PreparedImage, prepareImage };
@@ -0,0 +1,52 @@
1
+ const MAX_IMAGE_EDGE = 2560;
2
+ const RESIZABLE = {
3
+ "image/jpeg": 0.85,
4
+ "image/webp": 0.85,
5
+ "image/png": void 0
6
+ // lossless; quality does not apply
7
+ };
8
+ async function prepareImage(file, maxEdge = MAX_IMAGE_EDGE) {
9
+ let bitmap;
10
+ try {
11
+ bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
12
+ } catch {
13
+ return { file, resized: false };
14
+ }
15
+ const { width, height } = bitmap;
16
+ const scale = Math.min(1, maxEdge / Math.max(width, height));
17
+ if (scale === 1 || !(file.type in RESIZABLE)) {
18
+ bitmap.close();
19
+ return { file, width, height, resized: false };
20
+ }
21
+ const w = Math.round(width * scale);
22
+ const h = Math.round(height * scale);
23
+ const canvas = document.createElement("canvas");
24
+ canvas.width = w;
25
+ canvas.height = h;
26
+ const ctx = canvas.getContext("2d");
27
+ if (!ctx) {
28
+ bitmap.close();
29
+ return { file, width, height, resized: false };
30
+ }
31
+ ctx.imageSmoothingEnabled = true;
32
+ ctx.imageSmoothingQuality = "high";
33
+ ctx.drawImage(bitmap, 0, 0, w, h);
34
+ bitmap.close();
35
+ const blob = await new Promise(
36
+ (resolve) => canvas.toBlob(resolve, file.type, RESIZABLE[file.type])
37
+ );
38
+ if (!blob || blob.type !== file.type) {
39
+ return { file, width, height, resized: false };
40
+ }
41
+ return {
42
+ file: new File([blob], file.name, { type: file.type, lastModified: Date.now() }),
43
+ width: w,
44
+ height: h,
45
+ resized: true
46
+ };
47
+ }
48
+ export {
49
+ MAX_IMAGE_EDGE,
50
+ prepareImage
51
+ };
52
+ //# sourceMappingURL=resizeImage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/react/resizeImage.ts"],"sourcesContent":["/**\n * Longest edge an uploaded image is scaled down to — WordPress's default \"big\n * image\" threshold. Enough for full-width heroes on high-density screens,\n * while a 12-megapixel phone photo (~4000px) shrinks to a fraction of its size.\n */\nexport const MAX_IMAGE_EDGE = 2560;\n\n/**\n * Types the browser can re-encode faithfully, with the quality used for each.\n * GIF is left alone because a canvas keeps only the first frame, and AVIF\n * because most browsers cannot encode it — both upload unchanged.\n */\nconst RESIZABLE: Record<string, number | undefined> = {\n \"image/jpeg\": 0.85,\n \"image/webp\": 0.85,\n \"image/png\": undefined, // lossless; quality does not apply\n};\n\nexport interface PreparedImage {\n file: File;\n width?: number;\n height?: number;\n resized: boolean;\n}\n\n/**\n * Scales an image down before upload, in the browser.\n *\n * This runs client-side because uploads go straight to storage and never pass\n * through the server. That makes it a convenience rather than a guarantee: the\n * server's 10 MB cap and the bucket's own limit remain the hard ceiling.\n *\n * Re-encoding also drops EXIF data, including GPS location from phone photos.\n * Images already within the limit are uploaded untouched, never recompressed.\n */\nexport async function prepareImage(\n file: File,\n maxEdge: number = MAX_IMAGE_EDGE,\n): Promise<PreparedImage> {\n let bitmap: ImageBitmap;\n try {\n // Apply EXIF orientation, or re-encoded phone photos come out sideways.\n bitmap = await createImageBitmap(file, { imageOrientation: \"from-image\" });\n } catch {\n // Not decodable here; let the server and storage decide.\n return { file, resized: false };\n }\n\n const { width, height } = bitmap;\n const scale = Math.min(1, maxEdge / Math.max(width, height));\n\n if (scale === 1 || !(file.type in RESIZABLE)) {\n bitmap.close();\n return { file, width, height, resized: false };\n }\n\n const w = Math.round(width * scale);\n const h = Math.round(height * scale);\n const canvas = document.createElement(\"canvas\");\n canvas.width = w;\n canvas.height = h;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) {\n bitmap.close();\n return { file, width, height, resized: false };\n }\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = \"high\";\n ctx.drawImage(bitmap, 0, 0, w, h);\n bitmap.close();\n\n const blob = await new Promise<Blob | null>((resolve) =>\n canvas.toBlob(resolve, file.type, RESIZABLE[file.type]),\n );\n\n // Browsers quietly fall back to PNG for types they cannot encode; keep the\n // original rather than upload a mislabelled or bloated file.\n if (!blob || blob.type !== file.type) {\n return { file, width, height, resized: false };\n }\n\n return {\n file: new File([blob], file.name, { type: file.type, lastModified: Date.now() }),\n width: w,\n height: h,\n resized: true,\n };\n}\n"],"mappings":"AAKO,MAAM,iBAAiB;AAO9B,MAAM,YAAgD;AAAA,EACpD,cAAc;AAAA,EACd,cAAc;AAAA,EACd,aAAa;AAAA;AACf;AAmBA,eAAsB,aACpB,MACA,UAAkB,gBACM;AACxB,MAAI;AACJ,MAAI;AAEF,aAAS,MAAM,kBAAkB,MAAM,EAAE,kBAAkB,aAAa,CAAC;AAAA,EAC3E,QAAQ;AAEN,WAAO,EAAE,MAAM,SAAS,MAAM;AAAA,EAChC;AAEA,QAAM,EAAE,OAAO,OAAO,IAAI;AAC1B,QAAM,QAAQ,KAAK,IAAI,GAAG,UAAU,KAAK,IAAI,OAAO,MAAM,CAAC;AAE3D,MAAI,UAAU,KAAK,EAAE,KAAK,QAAQ,YAAY;AAC5C,WAAO,MAAM;AACb,WAAO,EAAE,MAAM,OAAO,QAAQ,SAAS,MAAM;AAAA,EAC/C;AAEA,QAAM,IAAI,KAAK,MAAM,QAAQ,KAAK;AAClC,QAAM,IAAI,KAAK,MAAM,SAAS,KAAK;AACnC,QAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,SAAO,QAAQ;AACf,SAAO,SAAS;AAEhB,QAAM,MAAM,OAAO,WAAW,IAAI;AAClC,MAAI,CAAC,KAAK;AACR,WAAO,MAAM;AACb,WAAO,EAAE,MAAM,OAAO,QAAQ,SAAS,MAAM;AAAA,EAC/C;AACA,MAAI,wBAAwB;AAC5B,MAAI,wBAAwB;AAC5B,MAAI,UAAU,QAAQ,GAAG,GAAG,GAAG,CAAC;AAChC,SAAO,MAAM;AAEb,QAAM,OAAO,MAAM,IAAI;AAAA,IAAqB,CAAC,YAC3C,OAAO,OAAO,SAAS,KAAK,MAAM,UAAU,KAAK,IAAI,CAAC;AAAA,EACxD;AAIA,MAAI,CAAC,QAAQ,KAAK,SAAS,KAAK,MAAM;AACpC,WAAO,EAAE,MAAM,OAAO,QAAQ,SAAS,MAAM;AAAA,EAC/C;AAEA,SAAO;AAAA,IACL,MAAM,IAAI,KAAK,CAAC,IAAI,GAAG,KAAK,MAAM,EAAE,MAAM,KAAK,MAAM,cAAc,KAAK,IAAI,EAAE,CAAC;AAAA,IAC/E,OAAO;AAAA,IACP,QAAQ;AAAA,IACR,SAAS;AAAA,EACX;AACF;","names":[]}