@fullgrainsoftware/portal 0.10.0 → 0.12.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.
- package/README.md +95 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/inquiries.d.ts +28 -0
- package/dist/inquiries.js +58 -0
- package/dist/manifest.d.ts +18 -0
- package/dist/manifest.js +43 -0
- package/dist/photos.d.ts +37 -0
- package/dist/photos.js +121 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,6 +47,8 @@ export default defineSite({
|
|
|
47
47
|
});
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
A repeatable section whose items are mostly a picture can take photos in bulk from the panel: set `batchUpload: true` (the section has exactly one image field) or `batchUpload: "<image field key>"`. Each picked image becomes one item with every other field empty, so no other field of that section may be `required`.
|
|
51
|
+
|
|
50
52
|
## Read content in your pages
|
|
51
53
|
|
|
52
54
|
```tsx
|
|
@@ -157,6 +159,99 @@ bad key, or any other unexpected response — already logged via
|
|
|
157
159
|
`console.error`), fall back to a mailto/phone link rather than a dead form.
|
|
158
160
|
Ask your Fullgrain Software contact for the `INQUIRY_API_KEY` (`abik_…`).
|
|
159
161
|
|
|
162
|
+
### Attachments (photos)
|
|
163
|
+
|
|
164
|
+
A contact form can carry up to five photos. The panel admin turns **Accept
|
|
165
|
+
photos** on for the site first (the site's Inbox tab); until then every
|
|
166
|
+
upload answers `disabled`. Photos go browser → the site's own route →
|
|
167
|
+
`uploadPhoto`, one photo per request, and the message then names them by id.
|
|
168
|
+
|
|
169
|
+
In the browser, prepare each picked file before it leaves the device:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { PHOTO_LIMITS, PhotoError, isPhotoCandidate, preparePhoto } from "@fullgrainsoftware/portal";
|
|
173
|
+
|
|
174
|
+
// <input type="file" accept={PHOTO_LIMITS.accept} multiple> — at most PHOTO_LIMITS.perInquiry (5) photos
|
|
175
|
+
if (!isPhotoCandidate(file)) { /* not a photo — tell the visitor, skip it */ }
|
|
176
|
+
try {
|
|
177
|
+
const jpeg = await preparePhoto(file); // a JPEG File, ≤ 1568 px on the long side, EXIF (GPS included) dropped
|
|
178
|
+
const body = new FormData();
|
|
179
|
+
body.append("file", jpeg);
|
|
180
|
+
const res = await fetch("/api/contact/photo", { method: "POST", body }); // the site's route, one photo per request → { id } or an error word
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err instanceof PhotoError) showOnTile(err.message); // err.code: "unsupported" | "unreadable" | "too_large"
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`preparePhoto(file, { maxEdge?, quality? })` decodes with the browser's own
|
|
187
|
+
decoder (keeping the photo's orientation), scales to fit `PHOTO_LIMITS.maxEdge`
|
|
188
|
+
(1568) without enlarging, and re-encodes as `<name>.jpg` at quality 0.85.
|
|
189
|
+
It is browser-only: on the server it rejects with `unsupported`. Every failure
|
|
190
|
+
is a `PhotoError` whose `message` is written for the visitor and safe to show
|
|
191
|
+
as is: `unsupported` (not a type it takes), `unreadable` (the browser couldn't
|
|
192
|
+
decode it, or the file is corrupt), `too_large` (still over
|
|
193
|
+
`PHOTO_LIMITS.maxUploadBytes`, 4 MiB, after the resize). `isPhotoCandidate` is
|
|
194
|
+
the same type check, for the picker's own validation: the
|
|
195
|
+
`PHOTO_LIMITS.accept` types (JPEG, PNG, WebP, GIF), the HEIC family, and a
|
|
196
|
+
file with no type (or `application/octet-stream`) whose name ends in one of
|
|
197
|
+
those extensions. A HEIC reaches the decoder: Safari and iOS decode it, other
|
|
198
|
+
browsers reject it as `unreadable`. `PHOTO_LIMITS.accept` leaves HEIC out on
|
|
199
|
+
purpose, so iOS converts to JPEG in the picker; the SDK ships no HEIC decoder.
|
|
200
|
+
|
|
201
|
+
On the server, the site's route forwards the file and the submit names the ids:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
// app/api/contact/photo/route.ts
|
|
205
|
+
import { PHOTO_LIMITS, createInquiries } from "@fullgrainsoftware/portal";
|
|
206
|
+
|
|
207
|
+
const form = await req.formData();
|
|
208
|
+
const file = form.get("file");
|
|
209
|
+
if (!(file instanceof Blob)) return badRequest();
|
|
210
|
+
if (file.size > PHOTO_LIMITS.maxUploadBytes) return tooLarge();
|
|
211
|
+
const apiKey = process.env.INQUIRY_API_KEY;
|
|
212
|
+
if (!apiKey?.startsWith("abik_")) return unavailable(); // 503 — "photos can't be added right now"
|
|
213
|
+
const inquiries = createInquiries({ apiKey }); // inside the handler, as for submit
|
|
214
|
+
const result = await inquiries.uploadPhoto({ file, meta: { ip, userAgent } }); // a File's own name is sent as `name`
|
|
215
|
+
// { ok: true, id, width, height, bytes }
|
|
216
|
+
// { ok: false, error: "invalid", issues: Record<string, string> } e.g. issues.file: "That photo couldn't be read."
|
|
217
|
+
// { ok: false, error: "too_large" | "unsupported_type" | "rate_limited" | "disabled" | "unavailable" }
|
|
218
|
+
|
|
219
|
+
// app/api/contact/route.ts — later, with the ids the browser kept, in display order
|
|
220
|
+
await inquiries.submit({ name, email, message, attachments: ids /* ≤ 5 */ });
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The panel re-checks every photo (by its own bytes, never the type the browser
|
|
224
|
+
claims), re-encodes it, stores it privately, and keeps an unattached upload for 24
|
|
225
|
+
hours: `submit` must name it within that window, or it answers `invalid` with
|
|
226
|
+
`issues.attachments` ("One of the photos expired. Add it again."; with
|
|
227
|
+
**Accept photos** off, "This site doesn't accept photos."); show that issue on
|
|
228
|
+
the photo strip. Removing a photo before sending just drops its id. Turn the
|
|
229
|
+
upload result into copy: `invalid` → the first text in `issues` (usually
|
|
230
|
+
`issues.file`; an over-long name is `issues.name`),
|
|
231
|
+
`too_large` → "That photo is too big", `unsupported_type` → "Use a JPEG or
|
|
232
|
+
PNG", `rate_limited` → "Try again in a few minutes" (20 uploads per visitor
|
|
233
|
+
per 10 minutes, 300 per site per hour), and `disabled`, `unavailable` or a
|
|
234
|
+
missing key → "Photos can't be added right now — send the message and email
|
|
235
|
+
them to us." **A photo failure never disables sending the message**: the form
|
|
236
|
+
submits with whichever photos succeeded. A `rate_limited` submit leaves the
|
|
237
|
+
photos attachable, so keep the ids and send them again on the retry.
|
|
238
|
+
|
|
239
|
+
Keep each request to one photo. The panel takes at most 4 MiB per file
|
|
240
|
+
(`uploadPhoto` sends whatever it is given; check the size in your route), and
|
|
241
|
+
Vercel refuses a function body over 4.5 MB with its own 413 before the code
|
|
242
|
+
runs, on your route as on the panel's (`uploadPhoto` maps any 413, JSON or
|
|
243
|
+
not, to `too_large`), so several photos in one request fail where one would
|
|
244
|
+
not.
|
|
245
|
+
|
|
246
|
+
Two rules every site keeps:
|
|
247
|
+
|
|
248
|
+
- **Never show a visitor's photo on the public site.** Photos are for the
|
|
249
|
+
business's inbox only; the panel serves them to signed-in users and nowhere
|
|
250
|
+
else.
|
|
251
|
+
- **Reviews refuse attachments.** A `form: "review"` submit with
|
|
252
|
+
`attachments` is `invalid` ("Photos can't be attached to a review."), so
|
|
253
|
+
leave the picker off review forms.
|
|
254
|
+
|
|
160
255
|
## Bookings (appointments)
|
|
161
256
|
|
|
162
257
|
For sites whose business takes appointments. **Server-only** — the API key
|
package/dist/index.d.ts
CHANGED
|
@@ -8,8 +8,10 @@ export type { CMSComponent, CMSPage, CMSReview, CMSSection, CMSSectionGroup, CMS
|
|
|
8
8
|
export { createCommerce } from "./commerce.js";
|
|
9
9
|
export type { CartPreview, CheckoutInput, CheckoutResult, Commerce, LineIssue, OrderStatus, ProductDetail, ProductSummary, StoreStatus, VariantView, } from "./commerce.js";
|
|
10
10
|
export { createInquiries } from "./inquiries.js";
|
|
11
|
-
export type { Inquiries, InquiryInput, InquirySubmitResult } from "./inquiries.js";
|
|
11
|
+
export type { Inquiries, InquiryInput, InquirySubmitResult, InquiryUploadInput, InquiryUploadResult, } from "./inquiries.js";
|
|
12
12
|
export { initCmsPreview, normalizeSectionTitle, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
|
|
13
13
|
export type { CmsPreviewOptions, PreviewInbound, PreviewOutbound } from "./preview.js";
|
|
14
|
+
export { PHOTO_LIMITS, PhotoError, isPhotoCandidate, preparePhoto } from "./photos.js";
|
|
15
|
+
export type { PhotoErrorCode, PreparePhotoOptions } from "./photos.js";
|
|
14
16
|
export { createBookings } from "./bookings.js";
|
|
15
17
|
export type { AvailabilityDay, AvailabilityResult, BookInput, BookResult, BookingCustomer, BookingService, BookingStatus, BookingView, Bookings, GetBookingResult, ManageResult, RequestTimeInput, RequestTimeResult, ServicesResult, } from "./bookings.js";
|
package/dist/index.js
CHANGED
|
@@ -5,4 +5,5 @@ export { readReviews } from "./reviews.js";
|
|
|
5
5
|
export { createCommerce } from "./commerce.js";
|
|
6
6
|
export { createInquiries } from "./inquiries.js";
|
|
7
7
|
export { initCmsPreview, normalizeSectionTitle, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
|
|
8
|
+
export { PHOTO_LIMITS, PhotoError, isPhotoCandidate, preparePhoto } from "./photos.js";
|
|
8
9
|
export { createBookings } from "./bookings.js";
|
package/dist/inquiries.d.ts
CHANGED
|
@@ -13,11 +13,37 @@ export interface InquiryInput {
|
|
|
13
13
|
page?: string | null;
|
|
14
14
|
/** A hidden field a human never fills; non-empty means a bot. */
|
|
15
15
|
honeypot?: string | null;
|
|
16
|
+
/** Ids from `uploadPhoto`, in display order, at most 5. */
|
|
17
|
+
attachments?: string[];
|
|
16
18
|
meta?: {
|
|
17
19
|
ip?: string | null;
|
|
18
20
|
userAgent?: string | null;
|
|
19
21
|
};
|
|
20
22
|
}
|
|
23
|
+
export interface InquiryUploadInput {
|
|
24
|
+
/** The photo: a browser `File` passed through, or any `Blob`. */
|
|
25
|
+
file: Blob;
|
|
26
|
+
/** The visitor's filename, shown in the inbox. Defaults to the `File`'s own name. */
|
|
27
|
+
name?: string;
|
|
28
|
+
meta?: {
|
|
29
|
+
ip?: string | null;
|
|
30
|
+
userAgent?: string | null;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export type InquiryUploadResult = {
|
|
34
|
+
ok: true;
|
|
35
|
+
id: string;
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
bytes: number;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
error: "invalid";
|
|
42
|
+
issues: Record<string, string>;
|
|
43
|
+
} | {
|
|
44
|
+
ok: false;
|
|
45
|
+
error: "too_large" | "unsupported_type" | "rate_limited" | "disabled" | "unavailable";
|
|
46
|
+
};
|
|
21
47
|
export type InquirySubmitResult = {
|
|
22
48
|
ok: true;
|
|
23
49
|
id: string;
|
|
@@ -31,6 +57,8 @@ export type InquirySubmitResult = {
|
|
|
31
57
|
};
|
|
32
58
|
export interface Inquiries {
|
|
33
59
|
submit(input: InquiryInput): Promise<InquirySubmitResult>;
|
|
60
|
+
/** Stores one photo ahead of the submit; pass the returned id in `attachments`. */
|
|
61
|
+
uploadPhoto(input: InquiryUploadInput): Promise<InquiryUploadResult>;
|
|
34
62
|
}
|
|
35
63
|
export declare function createInquiries(opts: {
|
|
36
64
|
apiKey: string;
|
package/dist/inquiries.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
into its own success, "try again in a few minutes", or fallback state. */
|
|
6
6
|
const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
|
|
7
7
|
const TIMEOUT_MS = 10_000;
|
|
8
|
+
/** An upload carries up to 4 MiB over the hop, so it gets longer than a submit. */
|
|
9
|
+
const UPLOAD_TIMEOUT_MS = 20_000;
|
|
8
10
|
export function createInquiries(opts) {
|
|
9
11
|
if (typeof window !== "undefined") {
|
|
10
12
|
throw new Error("createInquiries is server-only — never expose INQUIRY_API_KEY to the browser");
|
|
@@ -14,6 +16,7 @@ export function createInquiries(opts) {
|
|
|
14
16
|
}
|
|
15
17
|
const base = (opts.baseUrl ?? process.env.AB_CMS_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
16
18
|
const path = "/api/inquiries/submit";
|
|
19
|
+
const uploadPath = "/api/inquiries/uploads";
|
|
17
20
|
const unavailable = (why) => {
|
|
18
21
|
console.error(`[fullgrain inquiries] ${why}`);
|
|
19
22
|
return { ok: false, error: "unavailable" };
|
|
@@ -48,5 +51,60 @@ export function createInquiries(opts) {
|
|
|
48
51
|
return { ok: false, error: "rate_limited" };
|
|
49
52
|
return unavailable(`unexpected response ${res.status}${body?.error ? ` (${body.error})` : ""} from ${base}${path}`);
|
|
50
53
|
},
|
|
54
|
+
async uploadPhoto(input) {
|
|
55
|
+
const form = new FormData();
|
|
56
|
+
// A File carries its own name; an explicit `name` wins over it.
|
|
57
|
+
const name = input.name ?? (typeof input.file.name === "string" ? input.file.name : undefined);
|
|
58
|
+
if (name)
|
|
59
|
+
form.append("file", input.file, name);
|
|
60
|
+
else
|
|
61
|
+
form.append("file", input.file);
|
|
62
|
+
if (name)
|
|
63
|
+
form.append("name", name);
|
|
64
|
+
if (input.meta?.ip != null)
|
|
65
|
+
form.append("ip", input.meta.ip);
|
|
66
|
+
if (input.meta?.userAgent != null)
|
|
67
|
+
form.append("user_agent", input.meta.userAgent);
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
// No Content-Type by hand: the runtime writes the multipart boundary.
|
|
71
|
+
res = await fetch(`${base}${uploadPath}`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { Authorization: `Bearer ${opts.apiKey}` },
|
|
74
|
+
body: form,
|
|
75
|
+
cache: "no-store",
|
|
76
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return unavailable(`request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
}
|
|
82
|
+
// Before the content-type check: above 4.5 MB Vercel answers its own HTML 413.
|
|
83
|
+
if (res.status === 413)
|
|
84
|
+
return { ok: false, error: "too_large" };
|
|
85
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
86
|
+
if (!contentType.includes("application/json")) {
|
|
87
|
+
return unavailable(`non-JSON response ${res.status} from ${base}${uploadPath}`);
|
|
88
|
+
}
|
|
89
|
+
const body = (await res.json().catch(() => null));
|
|
90
|
+
if (res.status === 200 && body?.ok && typeof body.id === "string") {
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
id: body.id,
|
|
94
|
+
width: Number(body.width ?? 0),
|
|
95
|
+
height: Number(body.height ?? 0),
|
|
96
|
+
bytes: Number(body.bytes ?? 0),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (res.status === 400 && body?.error === "invalid")
|
|
100
|
+
return { ok: false, error: "invalid", issues: body.issues ?? {} };
|
|
101
|
+
if (res.status === 403)
|
|
102
|
+
return { ok: false, error: "disabled" };
|
|
103
|
+
if (res.status === 415)
|
|
104
|
+
return { ok: false, error: "unsupported_type" };
|
|
105
|
+
if (res.status === 429)
|
|
106
|
+
return { ok: false, error: "rate_limited" };
|
|
107
|
+
return unavailable(`unexpected response ${res.status} from ${base}${uploadPath}`);
|
|
108
|
+
},
|
|
51
109
|
};
|
|
52
110
|
}
|
package/dist/manifest.d.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface ManifestSection {
|
|
|
18
18
|
title: string;
|
|
19
19
|
kind: "repeatable" | "singleton";
|
|
20
20
|
allowGroupCreation?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Batch photo uploads in the panel ("Add photos": one item per picked
|
|
23
|
+
* image, every other field empty). `true` when the section has exactly one
|
|
24
|
+
* image field, else that field's key. Repeatable sections only; no other
|
|
25
|
+
* field may be `required`.
|
|
26
|
+
*/
|
|
27
|
+
batchUpload?: true | string;
|
|
21
28
|
fields: Record<string, ManifestField>;
|
|
22
29
|
seed?: readonly ManifestSeedItem[];
|
|
23
30
|
}
|
|
@@ -41,6 +48,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
41
48
|
title: z.ZodString;
|
|
42
49
|
kind: z.ZodEnum<["repeatable", "singleton"]>;
|
|
43
50
|
allowGroupCreation: z.ZodOptional<z.ZodBoolean>;
|
|
51
|
+
batchUpload: z.ZodOptional<z.ZodUnion<[z.ZodLiteral<true>, z.ZodString]>>;
|
|
44
52
|
fields: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
45
53
|
label: z.ZodString;
|
|
46
54
|
type: z.ZodEnum<["text", "image", "color", "audio", "video"]>;
|
|
@@ -90,6 +98,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
90
98
|
generateMp4?: boolean | undefined;
|
|
91
99
|
}>;
|
|
92
100
|
allowGroupCreation?: boolean | undefined;
|
|
101
|
+
batchUpload?: string | true | undefined;
|
|
93
102
|
seed?: {
|
|
94
103
|
values: Record<string, string>;
|
|
95
104
|
name: string;
|
|
@@ -108,6 +117,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
108
117
|
generateMp4?: boolean | undefined;
|
|
109
118
|
}>;
|
|
110
119
|
allowGroupCreation?: boolean | undefined;
|
|
120
|
+
batchUpload?: string | true | undefined;
|
|
111
121
|
seed?: {
|
|
112
122
|
values: Record<string, string>;
|
|
113
123
|
name: string;
|
|
@@ -126,6 +136,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
126
136
|
generateMp4?: boolean | undefined;
|
|
127
137
|
}>;
|
|
128
138
|
allowGroupCreation?: boolean | undefined;
|
|
139
|
+
batchUpload?: string | true | undefined;
|
|
129
140
|
seed?: {
|
|
130
141
|
values: Record<string, string>;
|
|
131
142
|
name: string;
|
|
@@ -144,6 +155,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
144
155
|
generateMp4?: boolean | undefined;
|
|
145
156
|
}>;
|
|
146
157
|
allowGroupCreation?: boolean | undefined;
|
|
158
|
+
batchUpload?: string | true | undefined;
|
|
147
159
|
seed?: {
|
|
148
160
|
values: Record<string, string>;
|
|
149
161
|
name: string;
|
|
@@ -166,6 +178,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
166
178
|
generateMp4?: boolean | undefined;
|
|
167
179
|
}>;
|
|
168
180
|
allowGroupCreation?: boolean | undefined;
|
|
181
|
+
batchUpload?: string | true | undefined;
|
|
169
182
|
seed?: {
|
|
170
183
|
values: Record<string, string>;
|
|
171
184
|
name: string;
|
|
@@ -188,6 +201,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
188
201
|
generateMp4?: boolean | undefined;
|
|
189
202
|
}>;
|
|
190
203
|
allowGroupCreation?: boolean | undefined;
|
|
204
|
+
batchUpload?: string | true | undefined;
|
|
191
205
|
seed?: {
|
|
192
206
|
values: Record<string, string>;
|
|
193
207
|
name: string;
|
|
@@ -213,6 +227,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
213
227
|
generateMp4?: boolean | undefined;
|
|
214
228
|
}>;
|
|
215
229
|
allowGroupCreation?: boolean | undefined;
|
|
230
|
+
batchUpload?: string | true | undefined;
|
|
216
231
|
seed?: {
|
|
217
232
|
values: Record<string, string>;
|
|
218
233
|
name: string;
|
|
@@ -238,6 +253,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
238
253
|
generateMp4?: boolean | undefined;
|
|
239
254
|
}>;
|
|
240
255
|
allowGroupCreation?: boolean | undefined;
|
|
256
|
+
batchUpload?: string | true | undefined;
|
|
241
257
|
seed?: {
|
|
242
258
|
values: Record<string, string>;
|
|
243
259
|
name: string;
|
|
@@ -263,6 +279,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
263
279
|
generateMp4?: boolean | undefined;
|
|
264
280
|
}>;
|
|
265
281
|
allowGroupCreation?: boolean | undefined;
|
|
282
|
+
batchUpload?: string | true | undefined;
|
|
266
283
|
seed?: {
|
|
267
284
|
values: Record<string, string>;
|
|
268
285
|
name: string;
|
|
@@ -288,6 +305,7 @@ export declare const siteManifestSchema: z.ZodEffects<z.ZodObject<{
|
|
|
288
305
|
generateMp4?: boolean | undefined;
|
|
289
306
|
}>;
|
|
290
307
|
allowGroupCreation?: boolean | undefined;
|
|
308
|
+
batchUpload?: string | true | undefined;
|
|
291
309
|
seed?: {
|
|
292
310
|
values: Record<string, string>;
|
|
293
311
|
name: string;
|
package/dist/manifest.js
CHANGED
|
@@ -29,6 +29,7 @@ const sectionSchema = z
|
|
|
29
29
|
title: z.string().trim().min(1),
|
|
30
30
|
kind: z.enum(["repeatable", "singleton"]),
|
|
31
31
|
allowGroupCreation: z.boolean().optional(),
|
|
32
|
+
batchUpload: z.union([z.literal(true), z.string().trim().min(1)]).optional(),
|
|
32
33
|
fields: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/, "field keys are lower-kebab"), fieldSchema),
|
|
33
34
|
seed: z.array(seedItemSchema).optional(),
|
|
34
35
|
})
|
|
@@ -64,6 +65,48 @@ const sectionSchema = z
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
}
|
|
68
|
+
// Batch uploads: the panel fills ONE image field per picked file and
|
|
69
|
+
// leaves every other field empty, so the target must be an image field
|
|
70
|
+
// of a repeatable section and nothing else may be required.
|
|
71
|
+
if (section.batchUpload !== undefined) {
|
|
72
|
+
if (section.kind !== "repeatable") {
|
|
73
|
+
ctx.addIssue({
|
|
74
|
+
code: z.ZodIssueCode.custom,
|
|
75
|
+
message: `batchUpload on section "${section.title}" needs kind: "repeatable"`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const imageKeys = Object.entries(section.fields)
|
|
79
|
+
.filter(([, field]) => field.type === "image")
|
|
80
|
+
.map(([key]) => key);
|
|
81
|
+
let target = null;
|
|
82
|
+
if (section.batchUpload === true) {
|
|
83
|
+
if (imageKeys.length === 1)
|
|
84
|
+
target = imageKeys[0];
|
|
85
|
+
else {
|
|
86
|
+
ctx.addIssue({
|
|
87
|
+
code: z.ZodIssueCode.custom,
|
|
88
|
+
message: `batchUpload: true on section "${section.title}" needs exactly one image field (found ${imageKeys.length}) — name the field key instead`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (imageKeys.includes(section.batchUpload)) {
|
|
93
|
+
target = section.batchUpload;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
ctx.addIssue({
|
|
97
|
+
code: z.ZodIssueCode.custom,
|
|
98
|
+
message: `batchUpload on section "${section.title}" names "${section.batchUpload}", which is not an image field of that section`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
for (const [key, field] of Object.entries(section.fields)) {
|
|
102
|
+
if (key !== target && field.required) {
|
|
103
|
+
ctx.addIssue({
|
|
104
|
+
code: z.ZodIssueCode.custom,
|
|
105
|
+
message: `batchUpload on section "${section.title}" can't fill required field "${key}" — a batch-made item leaves every other field empty`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
67
110
|
});
|
|
68
111
|
const pageSchema = z
|
|
69
112
|
.object({
|
package/dist/photos.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const PHOTO_LIMITS: {
|
|
2
|
+
readonly perInquiry: 5;
|
|
3
|
+
readonly maxEdge: 1568;
|
|
4
|
+
readonly maxUploadBytes: number;
|
|
5
|
+
readonly accept: "image/jpeg,image/png,image/webp,image/gif";
|
|
6
|
+
};
|
|
7
|
+
export type PhotoErrorCode = "unsupported" | "unreadable" | "too_large";
|
|
8
|
+
export declare class PhotoError extends Error {
|
|
9
|
+
readonly code: PhotoErrorCode;
|
|
10
|
+
constructor(code: PhotoErrorCode, options?: {
|
|
11
|
+
cause?: unknown;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export interface PreparePhotoOptions {
|
|
15
|
+
/** Longest side of the result in pixels. Default PHOTO_LIMITS.maxEdge (1568); anything not a finite number ≥ 1 uses it. */
|
|
16
|
+
maxEdge?: number;
|
|
17
|
+
/** JPEG quality in (0, 1]. Default 0.85; anything outside that range uses it. */
|
|
18
|
+
quality?: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Whether a picked file is worth handing to the browser's decoder: one of
|
|
22
|
+
* PHOTO_LIMITS.accept's types, or the HEIC family — by type, or by name when
|
|
23
|
+
* the browser reports no type (an empty type or application/octet-stream):
|
|
24
|
+
* a .jpg/.jpeg/.png/.webp/.gif name (some Android cloud and document pickers
|
|
25
|
+
* report no type) or a .heic/.heif/.hif name (Windows Chrome gives a .heic an
|
|
26
|
+
* empty type). A stated non-image type wins over the name. Safari and iOS
|
|
27
|
+
* decode HEIC natively; elsewhere preparePhoto then rejects it as
|
|
28
|
+
* "unreadable". PHOTO_LIMITS.accept itself leaves HEIC out on purpose, so iOS
|
|
29
|
+
* keeps converting to JPEG in the picker.
|
|
30
|
+
* Sites can reuse this for their own picker validation.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isPhotoCandidate(file: Pick<File, "name" | "type">): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Resize a picked image to a JPEG of at most `maxEdge` px on its long side,
|
|
35
|
+
* without EXIF. Browser-only: on the server it rejects with "unsupported".
|
|
36
|
+
*/
|
|
37
|
+
export declare function preparePhoto(file: File, options?: PreparePhotoOptions): Promise<File>;
|
package/dist/photos.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Browser helper for inquiry photos. A site calls preparePhoto in the
|
|
2
|
+
// visitor's browser before posting the picked file to its own route (which
|
|
3
|
+
// forwards it with inquiries.uploadPhoto). It decodes the image with the
|
|
4
|
+
// browser's own decoder, keeps the photo's orientation, scales it to at most
|
|
5
|
+
// PHOTO_LIMITS.maxEdge on the long side (never enlarging) and re-encodes it
|
|
6
|
+
// as a JPEG on a canvas filled white first, so transparency flattens onto
|
|
7
|
+
// white. Orientation is applied by the decoder: createImageBitmap's default
|
|
8
|
+
// is the photo's EXIF orientation. No imageOrientation option is passed, for
|
|
9
|
+
// browser compatibility: the "from-image" value exists only from Chrome 112,
|
|
10
|
+
// Firefox 111 and Safari 16, and an unknown enum value rejects the call. A
|
|
11
|
+
// HEIC is handed to the decoder too (see isPhotoCandidate): Safari and iOS
|
|
12
|
+
// decode it, other browsers reject it as "unreadable".
|
|
13
|
+
//
|
|
14
|
+
// The canvas re-encode carries no EXIF at all, so camera metadata — GPS
|
|
15
|
+
// coordinates included — never leaves the visitor's device.
|
|
16
|
+
//
|
|
17
|
+
// Failures reject with a PhotoError: a stable `code` for the site's logic and
|
|
18
|
+
// a `message` written for the visitor, safe to show as is (it never echoes
|
|
19
|
+
// the file's name or contents).
|
|
20
|
+
export const PHOTO_LIMITS = {
|
|
21
|
+
perInquiry: 5,
|
|
22
|
+
maxEdge: 1568,
|
|
23
|
+
maxUploadBytes: 4 * 1024 * 1024,
|
|
24
|
+
accept: "image/jpeg,image/png,image/webp,image/gif",
|
|
25
|
+
};
|
|
26
|
+
const MESSAGES = {
|
|
27
|
+
unsupported: "That file isn't a photo we can take. Use a JPEG or PNG.",
|
|
28
|
+
unreadable: "That photo couldn't be read. Try a JPEG or PNG.",
|
|
29
|
+
too_large: "That photo is too large.",
|
|
30
|
+
};
|
|
31
|
+
export class PhotoError extends Error {
|
|
32
|
+
code;
|
|
33
|
+
constructor(code, options) {
|
|
34
|
+
super(MESSAGES[code], options);
|
|
35
|
+
this.name = "PhotoError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const DEFAULT_QUALITY = 0.85;
|
|
40
|
+
const ACCEPTED_TYPES = PHOTO_LIMITS.accept.split(",");
|
|
41
|
+
const HEIC_TYPES = ["image/heic", "image/heif", "image/heic-sequence", "image/heif-sequence"];
|
|
42
|
+
const HEIC_NAME = /\.(heic|heif|hif)$/i;
|
|
43
|
+
const PHOTO_NAME = /\.(jpe?g|png|webp|gif)$/i;
|
|
44
|
+
/**
|
|
45
|
+
* Whether a picked file is worth handing to the browser's decoder: one of
|
|
46
|
+
* PHOTO_LIMITS.accept's types, or the HEIC family — by type, or by name when
|
|
47
|
+
* the browser reports no type (an empty type or application/octet-stream):
|
|
48
|
+
* a .jpg/.jpeg/.png/.webp/.gif name (some Android cloud and document pickers
|
|
49
|
+
* report no type) or a .heic/.heif/.hif name (Windows Chrome gives a .heic an
|
|
50
|
+
* empty type). A stated non-image type wins over the name. Safari and iOS
|
|
51
|
+
* decode HEIC natively; elsewhere preparePhoto then rejects it as
|
|
52
|
+
* "unreadable". PHOTO_LIMITS.accept itself leaves HEIC out on purpose, so iOS
|
|
53
|
+
* keeps converting to JPEG in the picker.
|
|
54
|
+
* Sites can reuse this for their own picker validation.
|
|
55
|
+
*/
|
|
56
|
+
export function isPhotoCandidate(file) {
|
|
57
|
+
const type = file.type.toLowerCase();
|
|
58
|
+
if (ACCEPTED_TYPES.includes(type) || HEIC_TYPES.includes(type))
|
|
59
|
+
return true;
|
|
60
|
+
if (type === "" || type === "application/octet-stream")
|
|
61
|
+
return PHOTO_NAME.test(file.name) || HEIC_NAME.test(file.name);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resize a picked image to a JPEG of at most `maxEdge` px on its long side,
|
|
66
|
+
* without EXIF. Browser-only: on the server it rejects with "unsupported".
|
|
67
|
+
*/
|
|
68
|
+
export async function preparePhoto(file, options = {}) {
|
|
69
|
+
if (typeof document === "undefined")
|
|
70
|
+
throw new PhotoError("unsupported");
|
|
71
|
+
if (!isPhotoCandidate(file))
|
|
72
|
+
throw new PhotoError("unsupported");
|
|
73
|
+
let bitmap;
|
|
74
|
+
try {
|
|
75
|
+
bitmap = await createImageBitmap(file);
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
throw new PhotoError("unreadable", { cause });
|
|
79
|
+
}
|
|
80
|
+
let blob;
|
|
81
|
+
try {
|
|
82
|
+
const maxEdge = typeof options.maxEdge === "number" && Number.isFinite(options.maxEdge) && options.maxEdge >= 1
|
|
83
|
+
? options.maxEdge
|
|
84
|
+
: PHOTO_LIMITS.maxEdge;
|
|
85
|
+
const quality = typeof options.quality === "number" && Number.isFinite(options.quality) && options.quality > 0 && options.quality <= 1
|
|
86
|
+
? options.quality
|
|
87
|
+
: DEFAULT_QUALITY;
|
|
88
|
+
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
|
|
89
|
+
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
90
|
+
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
91
|
+
const canvas = document.createElement("canvas");
|
|
92
|
+
canvas.width = width;
|
|
93
|
+
canvas.height = height;
|
|
94
|
+
const context = canvas.getContext("2d");
|
|
95
|
+
if (!context)
|
|
96
|
+
throw new PhotoError("unreadable");
|
|
97
|
+
// JPEG has no alpha: an unfilled canvas would encode transparent pixels as black.
|
|
98
|
+
context.fillStyle = "#ffffff";
|
|
99
|
+
context.fillRect(0, 0, width, height);
|
|
100
|
+
context.drawImage(bitmap, 0, 0, width, height);
|
|
101
|
+
const encoded = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality));
|
|
102
|
+
// Release the backing store now: Safari caps total canvas memory, and a
|
|
103
|
+
// site may prepare several photos at once.
|
|
104
|
+
canvas.width = canvas.height = 0;
|
|
105
|
+
if (!encoded)
|
|
106
|
+
throw new PhotoError("unreadable");
|
|
107
|
+
blob = encoded;
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
// A raw DOMException from createElement, getContext, drawImage or toBlob
|
|
111
|
+
// still reaches the site as a PhotoError, so its message is always safe to show.
|
|
112
|
+
throw cause instanceof PhotoError ? cause : new PhotoError("unreadable", { cause });
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
bitmap.close();
|
|
116
|
+
}
|
|
117
|
+
if (blob.size > PHOTO_LIMITS.maxUploadBytes)
|
|
118
|
+
throw new PhotoError("too_large");
|
|
119
|
+
const base = file.name.replace(/\.[^.]*$/, "") || "photo";
|
|
120
|
+
return new File([blob], `${base}.jpg`, { type: "image/jpeg", lastModified: file.lastModified });
|
|
121
|
+
}
|
package/package.json
CHANGED