@fullgrainsoftware/portal 0.11.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 +93 -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/photos.d.ts +37 -0
- package/dist/photos.js +121 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,6 +159,99 @@ bad key, or any other unexpected response — already logged via
|
|
|
159
159
|
`console.error`), fall back to a mailto/phone link rather than a dead form.
|
|
160
160
|
Ask your Fullgrain Software contact for the `INQUIRY_API_KEY` (`abik_…`).
|
|
161
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
|
+
|
|
162
255
|
## Bookings (appointments)
|
|
163
256
|
|
|
164
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/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