@stapel/cdn-react 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 (164) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +124 -0
  3. package/README.md +103 -0
  4. package/dist/api/cdnApi.d.ts +122 -0
  5. package/dist/api/cdnApi.d.ts.map +1 -0
  6. package/dist/api/cdnApi.js +27 -0
  7. package/dist/api/cdnApi.js.map +1 -0
  8. package/dist/api/generated/schema.d.ts +1050 -0
  9. package/dist/api/generated/schema.d.ts.map +1 -0
  10. package/dist/api/generated/schema.js +2 -0
  11. package/dist/api/generated/schema.js.map +1 -0
  12. package/dist/api/types.d.ts +76 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +2 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/default/ErrorAlert.d.ts +8 -0
  17. package/dist/default/ErrorAlert.d.ts.map +1 -0
  18. package/dist/default/ErrorAlert.js +26 -0
  19. package/dist/default/ErrorAlert.js.map +1 -0
  20. package/dist/default/ImageUploadField.d.ts +12 -0
  21. package/dist/default/ImageUploadField.d.ts.map +1 -0
  22. package/dist/default/ImageUploadField.js +48 -0
  23. package/dist/default/ImageUploadField.js.map +1 -0
  24. package/dist/default/MediaGalleryField.d.ts +14 -0
  25. package/dist/default/MediaGalleryField.d.ts.map +1 -0
  26. package/dist/default/MediaGalleryField.js +65 -0
  27. package/dist/default/MediaGalleryField.js.map +1 -0
  28. package/dist/default/index.d.ts +15 -0
  29. package/dist/default/index.d.ts.map +1 -0
  30. package/dist/default/index.js +13 -0
  31. package/dist/default/index.js.map +1 -0
  32. package/dist/default/phase.d.ts +17 -0
  33. package/dist/default/phase.d.ts.map +1 -0
  34. package/dist/default/phase.js +25 -0
  35. package/dist/default/phase.js.map +1 -0
  36. package/dist/flows/registry.d.ts +28 -0
  37. package/dist/flows/registry.d.ts.map +1 -0
  38. package/dist/flows/registry.js +26 -0
  39. package/dist/flows/registry.js.map +1 -0
  40. package/dist/headless/CdnProvider.d.ts +18 -0
  41. package/dist/headless/CdnProvider.d.ts.map +1 -0
  42. package/dist/headless/CdnProvider.js +14 -0
  43. package/dist/headless/CdnProvider.js.map +1 -0
  44. package/dist/headless/ImageUpload.d.ts +18 -0
  45. package/dist/headless/ImageUpload.d.ts.map +1 -0
  46. package/dist/headless/ImageUpload.js +16 -0
  47. package/dist/headless/ImageUpload.js.map +1 -0
  48. package/dist/headless/MediaUploader.d.ts +31 -0
  49. package/dist/headless/MediaUploader.d.ts.map +1 -0
  50. package/dist/headless/MediaUploader.js +30 -0
  51. package/dist/headless/MediaUploader.js.map +1 -0
  52. package/dist/headless/useUploadImage.d.ts +33 -0
  53. package/dist/headless/useUploadImage.d.ts.map +1 -0
  54. package/dist/headless/useUploadImage.js +118 -0
  55. package/dist/headless/useUploadImage.js.map +1 -0
  56. package/dist/headless/useUploadPreview.d.ts +22 -0
  57. package/dist/headless/useUploadPreview.d.ts.map +1 -0
  58. package/dist/headless/useUploadPreview.js +37 -0
  59. package/dist/headless/useUploadPreview.js.map +1 -0
  60. package/dist/headless/useUploadQueue.d.ts +83 -0
  61. package/dist/headless/useUploadQueue.d.ts.map +1 -0
  62. package/dist/headless/useUploadQueue.js +0 -0
  63. package/dist/headless/useUploadQueue.js.map +1 -0
  64. package/dist/i18n/errorsMap.d.ts +12 -0
  65. package/dist/i18n/errorsMap.d.ts.map +1 -0
  66. package/dist/i18n/errorsMap.js +22 -0
  67. package/dist/i18n/errorsMap.js.map +1 -0
  68. package/dist/i18n/es.d.ts +17 -0
  69. package/dist/i18n/es.d.ts.map +1 -0
  70. package/dist/i18n/es.js +65 -0
  71. package/dist/i18n/es.js.map +1 -0
  72. package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
  73. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  74. package/dist/i18n/generated/errors.es.gen.js +58 -0
  75. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  76. package/dist/i18n/generated/errors.gen.d.ts +353 -0
  77. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  78. package/dist/i18n/generated/errors.gen.js +180 -0
  79. package/dist/i18n/generated/errors.gen.js.map +1 -0
  80. package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
  81. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  82. package/dist/i18n/generated/errors.ru.gen.js +58 -0
  83. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  84. package/dist/i18n/keys.d.ts +61 -0
  85. package/dist/i18n/keys.d.ts.map +1 -0
  86. package/dist/i18n/keys.js +110 -0
  87. package/dist/i18n/keys.js.map +1 -0
  88. package/dist/i18n/ru.d.ts +21 -0
  89. package/dist/i18n/ru.d.ts.map +1 -0
  90. package/dist/i18n/ru.js +70 -0
  91. package/dist/i18n/ru.js.map +1 -0
  92. package/dist/index.d.ts +89 -0
  93. package/dist/index.d.ts.map +1 -0
  94. package/dist/index.js +83 -0
  95. package/dist/index.js.map +1 -0
  96. package/dist/model/context.d.ts +11 -0
  97. package/dist/model/context.d.ts.map +1 -0
  98. package/dist/model/context.js +15 -0
  99. package/dist/model/context.js.map +1 -0
  100. package/dist/model/hash.d.ts +36 -0
  101. package/dist/model/hash.d.ts.map +1 -0
  102. package/dist/model/hash.js +59 -0
  103. package/dist/model/hash.js.map +1 -0
  104. package/dist/model/limits.d.ts +86 -0
  105. package/dist/model/limits.d.ts.map +1 -0
  106. package/dist/model/limits.js +163 -0
  107. package/dist/model/limits.js.map +1 -0
  108. package/dist/model/queries.d.ts +29 -0
  109. package/dist/model/queries.d.ts.map +1 -0
  110. package/dist/model/queries.js +32 -0
  111. package/dist/model/queries.js.map +1 -0
  112. package/dist/model/queryKeys.d.ts +18 -0
  113. package/dist/model/queryKeys.d.ts.map +1 -0
  114. package/dist/model/queryKeys.js +18 -0
  115. package/dist/model/queryKeys.js.map +1 -0
  116. package/dist/model/refs.d.ts +54 -0
  117. package/dist/model/refs.d.ts.map +1 -0
  118. package/dist/model/refs.js +82 -0
  119. package/dist/model/refs.js.map +1 -0
  120. package/dist/model/runtime.d.ts +40 -0
  121. package/dist/model/runtime.d.ts.map +1 -0
  122. package/dist/model/runtime.js +23 -0
  123. package/dist/model/runtime.js.map +1 -0
  124. package/dist/model/upload.d.ts +88 -0
  125. package/dist/model/upload.d.ts.map +1 -0
  126. package/dist/model/upload.js +254 -0
  127. package/dist/model/upload.js.map +1 -0
  128. package/llms.txt +83 -0
  129. package/manifest.json +601 -0
  130. package/package.json +120 -0
  131. package/src/analytics/generated/events.json +7 -0
  132. package/src/api/cdnApi.ts +187 -0
  133. package/src/api/generated/schema.ts +1052 -0
  134. package/src/api/types.ts +86 -0
  135. package/src/default/ErrorAlert.tsx +43 -0
  136. package/src/default/ImageUploadField.tsx +131 -0
  137. package/src/default/MediaGalleryField.tsx +233 -0
  138. package/src/default/index.ts +14 -0
  139. package/src/default/phase.ts +34 -0
  140. package/src/flows/registry.ts +38 -0
  141. package/src/headless/CdnProvider.tsx +19 -0
  142. package/src/headless/ImageUpload.tsx +24 -0
  143. package/src/headless/MediaUploader.tsx +43 -0
  144. package/src/headless/useUploadImage.ts +155 -0
  145. package/src/headless/useUploadPreview.ts +48 -0
  146. package/src/headless/useUploadQueue.ts +411 -0
  147. package/src/i18n/errorsMap.ts +33 -0
  148. package/src/i18n/es.ts +81 -0
  149. package/src/i18n/generated/errors.es.gen.ts +64 -0
  150. package/src/i18n/generated/errors.gen.ts +211 -0
  151. package/src/i18n/generated/errors.json +403 -0
  152. package/src/i18n/generated/errors.ru.gen.ts +64 -0
  153. package/src/i18n/keys.ts +131 -0
  154. package/src/i18n/ru.ts +85 -0
  155. package/src/index.ts +154 -0
  156. package/src/model/context.tsx +25 -0
  157. package/src/model/hash.ts +67 -0
  158. package/src/model/limits.ts +207 -0
  159. package/src/model/queries.ts +64 -0
  160. package/src/model/queryKeys.ts +23 -0
  161. package/src/model/refs.ts +95 -0
  162. package/src/model/runtime.ts +52 -0
  163. package/src/model/upload.ts +365 -0
  164. package/tsconfig.json +26 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * One image, one slot — the avatar/cover shape, where a new pick REPLACES the
3
+ * old one rather than joining a queue.
4
+ *
5
+ * This is the hook `profiles-react`'s `useSetAvatar` is meant to be built on:
6
+ * it owns the upload half (validate → hash → pre-check → POST → variants) and
7
+ * hands back the `<type>/<hash>` reference, leaving the "…and store it on the
8
+ * profile" half where it belongs, in the pair that owns the profile.
9
+ */
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { toStapelApiError, useObjectUrlPreview } from "@stapel/core";
12
+ import type { StapelApiError } from "@stapel/core";
13
+ import type { CdnImage, CdnRef } from "../api/types.js";
14
+ import { useCdnRuntime } from "../model/context.js";
15
+ import { isUploadCanceled, runUpload } from "../model/upload.js";
16
+ import type { CdnUploadTarget, UploadPhase } from "../model/upload.js";
17
+
18
+ export interface UploadImageBag {
19
+ /**
20
+ * Upload a pick. Resolves the reference, or `null` when it failed or was
21
+ * canceled — `error` and `phase` say which.
22
+ */
23
+ upload(file: File): Promise<CdnRef | null>;
24
+ /** Abort the upload in flight. */
25
+ cancel(): void;
26
+ /** Forget the pick, its preview and its error. */
27
+ reset(): void;
28
+ /**
29
+ * A local object URL for the file being uploaded — render it the instant the
30
+ * pick happens, long before any server has seen it. Revoked when the pick
31
+ * changes and on unmount, by construction: this is core's
32
+ * `useObjectUrlPreview`, whose whole job is that lifetime.
33
+ */
34
+ readonly previewUrl: string | null;
35
+ readonly phase: UploadPhase;
36
+ readonly isPending: boolean;
37
+ readonly ref: CdnRef | null;
38
+ readonly image: CdnImage | null;
39
+ /** The pre-check hit: nothing was uploaded. */
40
+ readonly deduped: boolean;
41
+ readonly variantsReady: boolean;
42
+ readonly error: StapelApiError | null;
43
+ }
44
+
45
+ export function useUploadImage(options?: {
46
+ readonly target?: CdnUploadTarget;
47
+ }): UploadImageBag {
48
+ const runtime = useCdnRuntime();
49
+ const target: CdnUploadTarget = options?.target ?? { kind: "image" };
50
+
51
+ const [file, setFile] = useState<File | null>(null);
52
+ const [phase, setPhase] = useState<UploadPhase>("idle");
53
+ const [ref, setRef] = useState<CdnRef | null>(null);
54
+ const [image, setImage] = useState<CdnImage | null>(null);
55
+ const [deduped, setDeduped] = useState(false);
56
+ const [variantsReady, setVariantsReady] = useState(false);
57
+ const [error, setError] = useState<StapelApiError | null>(null);
58
+ const previewUrl = useObjectUrlPreview(file);
59
+
60
+ const controller = useRef<AbortController | null>(null);
61
+ const alive = useRef(true);
62
+ useEffect(() => {
63
+ alive.current = true;
64
+ return () => {
65
+ alive.current = false;
66
+ controller.current?.abort();
67
+ };
68
+ }, []);
69
+
70
+ const runtimeRef = useRef(runtime);
71
+ runtimeRef.current = runtime;
72
+ const targetRef = useRef(target);
73
+ targetRef.current = target;
74
+
75
+ const upload = useCallback(async (picked: File): Promise<CdnRef | null> => {
76
+ controller.current?.abort();
77
+ const own = new AbortController();
78
+ controller.current = own;
79
+
80
+ setFile(picked);
81
+ setRef(null);
82
+ setImage(null);
83
+ setDeduped(false);
84
+ setVariantsReady(false);
85
+ setError(null);
86
+ setPhase("hashing");
87
+
88
+ const current = runtimeRef.current;
89
+ try {
90
+ const outcome = await runUpload(current.api, picked, {
91
+ target: targetRef.current,
92
+ limits: current.limits.image,
93
+ signal: own.signal,
94
+ onPhase: (next) => {
95
+ if (!alive.current || own.signal.aborted) return;
96
+ if (next === "done" || next === "failed" || next === "canceled") return;
97
+ setPhase(next);
98
+ },
99
+ ...(current.variants !== undefined ? { variants: current.variants } : {}),
100
+ });
101
+ if (!alive.current) return outcome.ref;
102
+ setRef(outcome.ref);
103
+ setImage(outcome.image);
104
+ setDeduped(outcome.deduped);
105
+ setVariantsReady(outcome.variantsReady);
106
+ setPhase("done");
107
+ return outcome.ref;
108
+ } catch (failure) {
109
+ if (!alive.current) return null;
110
+ if (isUploadCanceled(failure)) {
111
+ setPhase("canceled");
112
+ return null;
113
+ }
114
+ // `toStapelApiError`, never a cast: a CDN call can fail without a Stapel
115
+ // envelope at all (network fault, an origin that answers HTML), and the
116
+ // cast that silences the compiler leaves `.code` undefined at runtime.
117
+ setError(toStapelApiError(failure));
118
+ setPhase("failed");
119
+ return null;
120
+ }
121
+ }, []);
122
+
123
+ const cancel = useCallback((): void => {
124
+ controller.current?.abort();
125
+ }, []);
126
+
127
+ const reset = useCallback((): void => {
128
+ controller.current?.abort();
129
+ setFile(null);
130
+ setPhase("idle");
131
+ setRef(null);
132
+ setImage(null);
133
+ setDeduped(false);
134
+ setVariantsReady(false);
135
+ setError(null);
136
+ }, []);
137
+
138
+ return {
139
+ upload,
140
+ cancel,
141
+ reset,
142
+ previewUrl,
143
+ phase,
144
+ isPending:
145
+ phase === "hashing" ||
146
+ phase === "checking" ||
147
+ phase === "uploading" ||
148
+ phase === "processing",
149
+ ref,
150
+ image,
151
+ deduped,
152
+ variantsReady,
153
+ error,
154
+ };
155
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * What to show for one queue item, at every stage of its life.
3
+ *
4
+ * A tile in an upload grid has three possible sources of pixels and they take
5
+ * turns: the local object URL of the pick (instantly, before any request), the
6
+ * CDN's smallest ready variant (once the ladder exists), and nothing at all
7
+ * (an item restored from a stored reference whose row has not been resolved
8
+ * yet). Every upload control in this fleet has re-derived that sequence by
9
+ * hand; this is it, once.
10
+ */
11
+ import { useObjectUrlPreview } from "@stapel/core";
12
+ import type { CdnImage } from "../api/types.js";
13
+ import type { UploadItem } from "./useUploadQueue.js";
14
+
15
+ export interface UploadPreview {
16
+ /** The object URL of the local pick, or `null`. Revoked for you. */
17
+ readonly localUrl: string | null;
18
+ /** A ready CDN thumbnail, or `null` while the ladder is still being made. */
19
+ readonly thumbnailUrl: string | null;
20
+ /** Whatever there is to render right now, preferring the local pick. */
21
+ readonly url: string | null;
22
+ }
23
+
24
+ /**
25
+ * The smallest generated variant, read from `variants_meta` rather than from
26
+ * a `variant_<n>_url` field.
27
+ *
28
+ * The flat fields are always POPULATED — they are computed paths, not
29
+ * evidence — so reading one before the background task has run yields a URL
30
+ * that 404s, which renders as a broken image and reads to the person as "my
31
+ * upload was rejected". `variants_meta` lists only what exists.
32
+ */
33
+ export function smallestVariantUrl(image: CdnImage | null): string | null {
34
+ if (image === null) return null;
35
+ let best: { tier: number; url: string } | null = null;
36
+ for (const variant of image.variants_meta) {
37
+ if (best === null || variant.tier < best.tier) {
38
+ best = { tier: variant.tier, url: variant.url };
39
+ }
40
+ }
41
+ return best?.url ?? null;
42
+ }
43
+
44
+ export function useUploadPreview(item: UploadItem): UploadPreview {
45
+ const localUrl = useObjectUrlPreview(item.file);
46
+ const thumbnailUrl = smallestVariantUrl(item.image);
47
+ return { localUrl, thumbnailUrl, url: localUrl ?? thumbnailUrl };
48
+ }
@@ -0,0 +1,411 @@
1
+ /**
2
+ * The upload bag — an ordered queue of picks, each running the dedup-first
3
+ * flow, with the reference list a consuming module actually stores hanging off
4
+ * the front of it.
5
+ *
6
+ * ── This is the shape listings' composer consumes ──────────────────────────
7
+ *
8
+ * `Listing.images_draft` is a list of opaque `<type>/<hash>` strings whose
9
+ * ORDER is the gallery order (`stapel_listings/serializers.py` dedupes it and
10
+ * otherwise keeps it verbatim). So the bag's contract with a composer is three
11
+ * things and no more:
12
+ *
13
+ * bag.refs the list to send, in display order
14
+ * bag.reorder the only way that order changes
15
+ * bag.settled an ActionAvailability that says whether it is safe to send
16
+ *
17
+ * `settled` is what stops the composer from publishing a draft whose photos
18
+ * are still in flight — the classic "I pressed Save and half the pictures are
19
+ * missing". It is an availability rather than a boolean because a blocked Save
20
+ * has to be able to say WHICH of the two reasons it is (still uploading / one
21
+ * failed), and a boolean cannot.
22
+ */
23
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
24
+ import {
25
+ actionAvailable,
26
+ actionBlocked,
27
+ firstBlock,
28
+ StapelApiError,
29
+ toStapelApiError,
30
+ } from "@stapel/core";
31
+ import type { ActionAvailability } from "@stapel/core";
32
+ import type { CdnImage, CdnRef } from "../api/types.js";
33
+ import { useCdnRuntime } from "../model/context.js";
34
+ import type { CdnIntakeLimits } from "../model/limits.js";
35
+ import { acceptAttribute, validateFile } from "../model/limits.js";
36
+ import { isUploadCanceled, runUpload } from "../model/upload.js";
37
+ import type {
38
+ CdnUploadTarget,
39
+ DedupSkipReason,
40
+ UploadPhase,
41
+ } from "../model/upload.js";
42
+ import { CDN_I18N_KEYS } from "../i18n/keys.js";
43
+
44
+ /** One pick in the queue, at whatever step of the flow it has reached. */
45
+ export interface UploadItem {
46
+ readonly id: string;
47
+ /**
48
+ * The picked file, or `null` for an item restored from a reference the
49
+ * caller already had — a reopened draft has references but no bytes.
50
+ */
51
+ readonly file: File | null;
52
+ readonly phase: UploadPhase;
53
+ /** `<type>/<hash>`, as soon as the CDN holds the bytes. */
54
+ readonly ref: CdnRef | null;
55
+ /** The CDN row, when this item's flow produced one. */
56
+ readonly image: CdnImage | null;
57
+ /** The pre-check hit: these bytes were already stored and nothing was sent. */
58
+ readonly deduped: boolean;
59
+ /** Why the pre-check did not run, when it did not. */
60
+ readonly dedupSkipped: DedupSkipReason | undefined;
61
+ /** Whether the variant ladder existed by the time the flow stopped waiting. */
62
+ readonly variantsReady: boolean;
63
+ readonly error: StapelApiError | null;
64
+ }
65
+
66
+ export interface UseUploadQueueOptions {
67
+ /**
68
+ * How many references this gallery may hold. The storefront's listing
69
+ * composer passes 10; a single-image field passes 1.
70
+ */
71
+ readonly max: number;
72
+ /** Where the bytes go. Default: `{ kind: "image" }`. */
73
+ readonly target?: CdnUploadTarget;
74
+ /** References this queue starts with — a reopened draft. */
75
+ readonly initialRefs?: readonly CdnRef[];
76
+ /** How many uploads run at once. Default 3. */
77
+ readonly concurrency?: number;
78
+ /** Called whenever `refs` changes, including on reorder and removal. */
79
+ readonly onRefsChange?: (refs: readonly CdnRef[]) => void;
80
+ }
81
+
82
+ export interface UploadQueueBag {
83
+ readonly items: readonly UploadItem[];
84
+ /**
85
+ * The settled references in display order — the value a composer stores.
86
+ * Items still uploading or failed contribute nothing, so this list is never
87
+ * a promise about bytes that are not there.
88
+ */
89
+ readonly refs: readonly CdnRef[];
90
+ readonly capacity: {
91
+ readonly max: number;
92
+ readonly used: number;
93
+ readonly remaining: number;
94
+ };
95
+ /** The deployment's ceilings for this intake, plus the `accept` string. */
96
+ readonly accept: {
97
+ readonly attribute: string;
98
+ readonly limits: CdnIntakeLimits;
99
+ };
100
+ /** Available while there is room; blocked WITH the reason when full. */
101
+ readonly canAdd: ActionAvailability;
102
+ /**
103
+ * Available when every item has a reference; blocked while any is in flight
104
+ * and blocked when any failed. This is the composer's submit gate.
105
+ */
106
+ readonly settled: ActionAvailability;
107
+ /**
108
+ * Admit files. Anything over `max` or refused by the client-side mirror is
109
+ * still ADMITTED, as a failed item carrying its refusal — a file that
110
+ * vanishes on drop teaches the person nothing. `remove` is how it leaves.
111
+ */
112
+ add(files: Iterable<File>): void;
113
+ /** Re-run a failed or canceled item's flow from the top. */
114
+ retry(id: string): void;
115
+ /** Abort an in-flight item. It stays in the queue, canceled, retryable. */
116
+ cancel(id: string): void;
117
+ /** Drop an item entirely. */
118
+ remove(id: string): void;
119
+ /** Move an item; this is what makes the gallery order editable. */
120
+ reorder(from: number, to: number): void;
121
+ }
122
+
123
+ let counter = 0;
124
+ function nextId(): string {
125
+ counter += 1;
126
+ return `upload-${String(counter)}`;
127
+ }
128
+
129
+ function refsOf(items: readonly UploadItem[]): readonly CdnRef[] {
130
+ const out: CdnRef[] = [];
131
+ for (const item of items) if (item.ref !== null) out.push(item.ref);
132
+ return out;
133
+ }
134
+
135
+ function isInFlight(phase: UploadPhase): boolean {
136
+ return (
137
+ phase === "hashing" ||
138
+ phase === "checking" ||
139
+ phase === "uploading" ||
140
+ phase === "processing"
141
+ );
142
+ }
143
+
144
+ const RESTORED: Omit<UploadItem, "id" | "ref"> = {
145
+ file: null,
146
+ phase: "done",
147
+ image: null,
148
+ deduped: false,
149
+ dedupSkipped: undefined,
150
+ variantsReady: false,
151
+ error: null,
152
+ };
153
+
154
+ export function useUploadQueue(options: UseUploadQueueOptions): UploadQueueBag {
155
+ const runtime = useCdnRuntime();
156
+ const limits = runtime.limits.image;
157
+ const target: CdnUploadTarget = options.target ?? { kind: "image" };
158
+ const concurrency = options.concurrency ?? 3;
159
+ const { max, onRefsChange } = options;
160
+
161
+ const [items, setItems] = useState<readonly UploadItem[]>(() =>
162
+ (options.initialRefs ?? []).map((ref) => ({ ...RESTORED, id: nextId(), ref }))
163
+ );
164
+
165
+ // Per-item abort handles and a started-set. The set is what keeps React's
166
+ // double-invoked effects (StrictMode, and any re-render that reorders the
167
+ // list) from starting the same upload twice — the queue is driven by an
168
+ // effect over state, so "have I already begun this id" cannot be answered
169
+ // from the state itself.
170
+ const controllers = useRef(new Map<string, AbortController>());
171
+ const started = useRef(new Set<string>());
172
+
173
+ // The latest callback, read from inside the runner without making the runner
174
+ // depend on the caller's render identity.
175
+ const onRefsChangeRef = useRef(onRefsChange);
176
+ onRefsChangeRef.current = onRefsChange;
177
+
178
+ const patch = useCallback(
179
+ (id: string, next: Partial<UploadItem>): void => {
180
+ setItems((current) => {
181
+ let changed = false;
182
+ const updated = current.map((item) => {
183
+ if (item.id !== id) return item;
184
+ changed = true;
185
+ return { ...item, ...next };
186
+ });
187
+ return changed ? updated : current;
188
+ });
189
+ },
190
+ []
191
+ );
192
+
193
+ const targetRef = useRef(target);
194
+ targetRef.current = target;
195
+ const limitsRef = useRef(limits);
196
+ limitsRef.current = limits;
197
+ const apiRef = useRef(runtime.api);
198
+ apiRef.current = runtime.api;
199
+ const variantsRef = useRef(runtime.variants);
200
+ variantsRef.current = runtime.variants;
201
+
202
+ const start = useCallback(
203
+ (item: UploadItem): void => {
204
+ const file = item.file;
205
+ if (file === null) return;
206
+ const controller = new AbortController();
207
+ controllers.current.set(item.id, controller);
208
+ patch(item.id, { phase: "hashing", error: null });
209
+
210
+ void runUpload(apiRef.current, file, {
211
+ target: targetRef.current,
212
+ limits: limitsRef.current,
213
+ signal: controller.signal,
214
+ onPhase: (phase) => {
215
+ // The terminal phases are written from the outcome below, together
216
+ // with the data they are terminal ABOUT — a phase that says "done"
217
+ // one render before the reference exists is a gallery that flickers
218
+ // an empty tile.
219
+ if (phase === "done" || phase === "failed" || phase === "canceled") {
220
+ return;
221
+ }
222
+ patch(item.id, { phase });
223
+ },
224
+ ...(variantsRef.current !== undefined
225
+ ? { variants: variantsRef.current }
226
+ : {}),
227
+ }).then(
228
+ (outcome) => {
229
+ controllers.current.delete(item.id);
230
+ patch(item.id, {
231
+ phase: "done",
232
+ ref: outcome.ref,
233
+ image: outcome.image,
234
+ deduped: outcome.deduped,
235
+ dedupSkipped: outcome.dedupSkipped,
236
+ variantsReady: outcome.variantsReady,
237
+ error: null,
238
+ });
239
+ },
240
+ (error: unknown) => {
241
+ controllers.current.delete(item.id);
242
+ if (isUploadCanceled(error)) {
243
+ patch(item.id, { phase: "canceled", error: null });
244
+ return;
245
+ }
246
+ patch(item.id, { phase: "failed", error: toStapelApiError(error) });
247
+ }
248
+ );
249
+ },
250
+ [patch]
251
+ );
252
+
253
+ // The pump: whenever the list changes, begin as many idle items as the
254
+ // concurrency budget allows.
255
+ useEffect(() => {
256
+ const running = items.filter((item) => isInFlight(item.phase)).length;
257
+ let budget = concurrency - running;
258
+ if (budget <= 0) return;
259
+ for (const item of items) {
260
+ if (budget <= 0) break;
261
+ if (item.phase !== "idle" || item.file === null) continue;
262
+ if (started.current.has(item.id)) continue;
263
+ started.current.add(item.id);
264
+ budget -= 1;
265
+ start(item);
266
+ }
267
+ }, [items, concurrency, start]);
268
+
269
+ // Abort everything still in flight when the control goes away. Without this
270
+ // a person who navigates off mid-upload leaves requests running against a
271
+ // component that can no longer report what happened to them.
272
+ useEffect(() => {
273
+ const inFlight = controllers.current;
274
+ return () => {
275
+ for (const controller of inFlight.values()) controller.abort();
276
+ inFlight.clear();
277
+ };
278
+ }, []);
279
+
280
+ const refs = useMemo(() => refsOf(items), [items]);
281
+ const refsKey = refs.join("");
282
+ const lastRefsKey = useRef<string | null>(null);
283
+ useEffect(() => {
284
+ if (lastRefsKey.current === refsKey) return;
285
+ lastRefsKey.current = refsKey;
286
+ onRefsChangeRef.current?.(refs);
287
+ // `refs` is derived from `refsKey`; depending on both would re-fire on
288
+ // every render that rebuilds the array with equal contents.
289
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the joined value, not the array identity
290
+ }, [refsKey]);
291
+
292
+ const add = useCallback(
293
+ (files: Iterable<File>): void => {
294
+ setItems((current) => {
295
+ const admitted: UploadItem[] = [];
296
+ let room = max - current.length;
297
+ for (const file of files) {
298
+ const overflow =
299
+ room <= 0
300
+ ? new StapelApiError({
301
+ // The gallery ceiling is the PAIR's rule, not stapel-cdn's:
302
+ // the backend has no opinion on how many photos a listing
303
+ // carries (stapel-listings does, and it passes the number
304
+ // in). So the refusal lives in this pair's own namespace
305
+ // rather than borrowing a code the server can never send —
306
+ // and its status is 0, because nothing was asked of any
307
+ // server. Inventing a 4xx would make a client-side rule
308
+ // indistinguishable from a refusal that came over the wire.
309
+ code: CDN_I18N_KEYS.blockedFull,
310
+ message: "The gallery is full",
311
+ status: 0,
312
+ params: { max },
313
+ })
314
+ : null;
315
+ room -= 1;
316
+ const refusal = overflow ?? validateFile(file, limitsRef.current);
317
+ admitted.push({
318
+ id: nextId(),
319
+ file,
320
+ phase: refusal === null ? "idle" : "failed",
321
+ ref: null,
322
+ image: null,
323
+ deduped: false,
324
+ dedupSkipped: undefined,
325
+ variantsReady: false,
326
+ error: refusal,
327
+ });
328
+ }
329
+ return admitted.length === 0 ? current : [...current, ...admitted];
330
+ });
331
+ },
332
+ [max]
333
+ );
334
+
335
+ const cancel = useCallback((id: string): void => {
336
+ const controller = controllers.current.get(id);
337
+ if (controller !== undefined) {
338
+ controller.abort();
339
+ return;
340
+ }
341
+ // An item still WAITING for a concurrency slot has no request to abort,
342
+ // and "cancel" has to mean cancel for it too — otherwise pressing it on a
343
+ // queued tile does nothing visible and the file uploads a moment later
344
+ // anyway. Marking the id as started keeps the pump from picking it up;
345
+ // `retry` clears that, so a canceled item is still retryable.
346
+ started.current.add(id);
347
+ setItems((current) =>
348
+ current.map((item) =>
349
+ item.id === id && item.phase === "idle"
350
+ ? { ...item, phase: "canceled" as const }
351
+ : item
352
+ )
353
+ );
354
+ }, []);
355
+
356
+ const remove = useCallback((id: string): void => {
357
+ controllers.current.get(id)?.abort();
358
+ controllers.current.delete(id);
359
+ started.current.delete(id);
360
+ setItems((current) => current.filter((item) => item.id !== id));
361
+ }, []);
362
+
363
+ const retry = useCallback((id: string): void => {
364
+ started.current.delete(id);
365
+ setItems((current) =>
366
+ current.map((item) =>
367
+ item.id === id ? { ...item, phase: "idle", error: null } : item
368
+ )
369
+ );
370
+ }, []);
371
+
372
+ const reorder = useCallback((from: number, to: number): void => {
373
+ setItems((current) => {
374
+ if (from === to) return current;
375
+ if (from < 0 || from >= current.length) return current;
376
+ if (to < 0 || to >= current.length) return current;
377
+ const next = [...current];
378
+ const [moved] = next.splice(from, 1);
379
+ if (moved === undefined) return current;
380
+ next.splice(to, 0, moved);
381
+ return next;
382
+ });
383
+ }, []);
384
+
385
+ const used = items.length;
386
+ const canAdd: ActionAvailability =
387
+ used >= max
388
+ ? actionBlocked(CDN_I18N_KEYS.blockedFull, { max })
389
+ : actionAvailable();
390
+
391
+ const pending = items.some((item) => isInFlight(item.phase) || item.phase === "idle");
392
+ const failed = items.some((item) => item.phase === "failed");
393
+ const settled = firstBlock(
394
+ pending ? actionBlocked(CDN_I18N_KEYS.blockedPending) : actionAvailable(),
395
+ failed ? actionBlocked(CDN_I18N_KEYS.blockedFailed) : actionAvailable()
396
+ );
397
+
398
+ return {
399
+ items,
400
+ refs,
401
+ capacity: { max, used, remaining: Math.max(0, max - used) },
402
+ accept: { attribute: acceptAttribute(limits), limits },
403
+ canAdd,
404
+ settled,
405
+ add,
406
+ retry,
407
+ cancel,
408
+ remove,
409
+ reorder,
410
+ };
411
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The pair's error map (frontend-standard §4 checklist #7): the generated
3
+ * `code → { status, params, remediation, en }` catalog plus a tiny `explain()`
4
+ * lookup. Backs the manifest `errors` block and gives hosts a mechanical UX
5
+ * branch beside `t(code, params)`. The map itself is generated from the
6
+ * backend registry (`pnpm gen:errors`); this file only adds the lookup helper
7
+ * and re-exports the public surface.
8
+ */
9
+ import { CDN_ERRORS } from "./generated/errors.gen.js";
10
+ import type { Remediation } from "./generated/errors.gen.js";
11
+
12
+ export {
13
+ CDN_ERRORS,
14
+ CDN_ERROR_CODES,
15
+ cdnErrorBundleEn,
16
+ } from "./generated/errors.gen.js";
17
+ export type {
18
+ CdnErrorCode,
19
+ CdnErrorSpec,
20
+ Remediation,
21
+ } from "./generated/errors.gen.js";
22
+
23
+ /**
24
+ * Resolve a backend error code to its remediation hint, or `undefined` for a
25
+ * code this module doesn't know (a cross-cutting `stapel.http.*` fallback, or
26
+ * one of this pair's own client-side rules such as
27
+ * `cdn.upload.blocked.full` — which is not a backend code and deliberately
28
+ * has no entry here).
29
+ */
30
+ export function explainCdnError(code: string): Remediation | undefined {
31
+ return (CDN_ERRORS as Record<string, { remediation: Remediation }>)[code]
32
+ ?.remediation;
33
+ }