@12-apps/storage 2.3.0 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/storage",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "description": "Image uploads that land at YOUR OWN ORIGIN, never at the bucket (12-20). Isomorphic core (format allowlist, upload ceiling, object-key grammar, rendition set, refusal taxonomy, wire schemas), host-mounted backend surface (./server: createApiStorage, the local-disk driver, the image-pipeline port, the orphan reclaim), Hono adapter (./hono), the S3-compatible driver behind its own subpath (./s3) and the bound picker + upload hook (./react). Standardized adoption contract in ADOPTING.md.",
6
6
  "exports": {
@@ -43,11 +43,12 @@
43
43
  }
44
44
  },
45
45
  "dependencies": {
46
- "@12-apps/ui": "^6.4.0",
46
+ "@12-apps/ui": "^6.9.0",
47
47
  "zod": "^4.3.5"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@12-apps/eslint-config": "^1.21.1",
51
+ "@12-apps/i18n": "^1.0.0",
51
52
  "@12-apps/typescript-config": "^1.20.1",
52
53
  "@12-apps/wiring": "^1.10.0",
53
54
  "@aws-sdk/client-s3": "^3.703.0",
package/src/en-US.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { StorageMessageContext, StorageMessages } from './problems';
2
+
3
+ /**
4
+ * The en-US pack — a NAMED factory a host passes by hand
5
+ * (`messages: EN_US_STORAGE_MESSAGES`), never a default. The filename is what
6
+ * exempts this file from the copy-portability gate, as `pt-BR.ts` beside it is
7
+ * exempt.
8
+ *
9
+ * `limit` is interpolated rather than hard-coded, for the reason the pt-BR pack
10
+ * gives: a host that raises `maxBytes` must not end up with a message naming
11
+ * the old number, which is the single commonest way this kind of copy goes
12
+ * stale. A translation that inlined the number would reintroduce exactly that.
13
+ */
14
+ export function EN_US_STORAGE_MESSAGES(context: StorageMessageContext): StorageMessages {
15
+ return {
16
+ invalid_base64: 'That image is not correctly base64-encoded.',
17
+ empty_file: 'That image is empty.',
18
+ file_too_large: `That image is larger than the ${context.limit} limit.`,
19
+ content_mismatch:
20
+ 'The image contents do not match the format you declared. Send a PNG, JPEG, WebP or GIF.',
21
+ unsupported_content_type: 'Unsupported format. Send a PNG, JPG, WebP or GIF.',
22
+ image_unreadable: 'That image could not be read — the file appears to be corrupted.',
23
+ image_dimensions_too_large: 'That image is too large in dimensions to process.',
24
+ storage_not_configured: 'Image storage has not been set up for this store yet.',
25
+ storage_unavailable: 'That image could not be saved right now. Try again in a moment.',
26
+ };
27
+ }
28
+
29
+ /** The 401 the mounted router answers when the host resolves no actor. */
30
+ export const EN_US_STORAGE_UNAUTHENTICATED = 'Not authenticated.';
package/src/index.ts CHANGED
@@ -76,6 +76,8 @@ export {
76
76
  type StorageProblem,
77
77
  } from './problems';
78
78
  export { PT_BR_STORAGE_MESSAGES, PT_BR_STORAGE_UNAUTHENTICATED } from './pt-BR';
79
+ export { EN_US_STORAGE_MESSAGES, EN_US_STORAGE_UNAUTHENTICATED } from './en-US';
80
+ export { STORAGE_MESSAGES, STORAGE_UNAUTHENTICATED } from './locales';
79
81
 
80
82
  export {
81
83
  imageSources,
package/src/locales.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { EN_US_STORAGE_MESSAGES, EN_US_STORAGE_UNAUTHENTICATED } from './en-US';
2
+ import type { StorageMessageContext, StorageMessages } from './problems';
3
+ import { PT_BR_STORAGE_MESSAGES, PT_BR_STORAGE_UNAUTHENTICATED } from './pt-BR';
4
+
5
+ /**
6
+ * Both languages, keyed by tag — what a host hands to `@12-apps/i18n` when the
7
+ * reader's language is a property of the request rather than of the deployment.
8
+ *
9
+ * The pack holds FACTORIES rather than tables, because that is what this
10
+ * package's copy already is: `limit` is interpolated so a host that raises
11
+ * `maxBytes` cannot end up with a message naming the old number. `localeCopy`
12
+ * therefore hands back a factory, and the host calls it with its context:
13
+ *
14
+ * ```ts
15
+ * const messages = localeCopy(STORAGE_MESSAGES)({ locale })({ limit });
16
+ * ```
17
+ *
18
+ * `LocalePack` is mirrored here rather than imported so the package stays
19
+ * liftable into a repo that has never heard of `@12-apps/i18n`.
20
+ */
21
+ type LocalePack<T> = { readonly 'pt-BR': T; readonly 'en-US': T };
22
+
23
+ export const STORAGE_MESSAGES = {
24
+ 'pt-BR': PT_BR_STORAGE_MESSAGES,
25
+ 'en-US': EN_US_STORAGE_MESSAGES,
26
+ } as const satisfies LocalePack<(context: StorageMessageContext) => StorageMessages>;
27
+
28
+ export const STORAGE_UNAUTHENTICATED = {
29
+ 'pt-BR': PT_BR_STORAGE_UNAUTHENTICATED,
30
+ 'en-US': EN_US_STORAGE_UNAUTHENTICATED,
31
+ } as const satisfies LocalePack<string>;
@@ -86,6 +86,7 @@ export function createWebStorage(config: WebStorageConfig): WebStorage {
86
86
  onChange={setImageKey}
87
87
  label={messages.fieldLabel}
88
88
  removeLabel={messages.fieldRemove}
89
+ uploadCopy={messages.field}
89
90
  helperText={messages.fieldHelper}
90
91
  />
91
92
  </Stack>
@@ -0,0 +1,49 @@
1
+ import type { WebStorageMessageContext, WebStorageMessages } from './failures';
2
+
3
+ /**
4
+ * The en-US pack — a NAMED factory a host passes by hand, never a default.
5
+ * `limit` is interpolated for the same reason the server pack does it: a host
6
+ * that raises the ceiling must not end up with copy naming the old number.
7
+ */
8
+ export function EN_US_WEB_STORAGE_MESSAGES(
9
+ context: WebStorageMessageContext,
10
+ ): WebStorageMessages {
11
+ return {
12
+ forbidden: 'Your account may not upload images for this store.',
13
+ unsupported_content_type: 'Unsupported format. Send a PNG, JPG, WebP or GIF.',
14
+ file_too_large: `That image is too large. The limit is ${context.limit}.`,
15
+ invalid_key: 'That image could not be found.',
16
+ not_found: 'That image is no longer available.',
17
+ session_expired: 'Your session has expired. Sign in again and repeat the upload.',
18
+ transport: 'Could not reach the server. Check your connection and try again.',
19
+ empty_file: 'Empty file (0 bytes). Pick the image again.',
20
+ file_too_large_upfront: ({ size, limit }) =>
21
+ `That image is too large: ${size}. The limit is ${limit} — shrink it and try again.`,
22
+ unknown_content_type: 'Could not identify the file type. Send a PNG, JPG or WebP.',
23
+ unsupported_content_type_upfront: ({ contentType }) =>
24
+ `Unsupported format (${contentType}). Send a PNG, JPG, WebP or GIF.`,
25
+ missing_key: 'The server accepted the image but returned no key. Try again.',
26
+ upload_failed_http: ({ status, detail }) =>
27
+ `Could not upload that image (HTTP ${status}${detail}). Try again in a moment.`,
28
+ upload_failed: 'Uploading that image failed. Try again in a moment.',
29
+
30
+ pageTitle: 'Store images',
31
+ pageIntro: `Send a PNG, JPG, WebP or GIF of up to ${context.limit}.`,
32
+ fieldLabel: 'Product photo',
33
+ fieldHelper: 'The image is resized in the browser before it is sent.',
34
+ fieldRemove: 'Remove image',
35
+ field: {
36
+ // `buttonLabel` is never read here — `ImageField` overrides it with
37
+ // `fieldLabel`. It is still answered, because the type is the whole of
38
+ // what `UploadButton` may render and a partial answer is not a pack.
39
+ buttonLabel: 'Product photo',
40
+ dropzoneHint: 'Drop the image here, or click to choose one',
41
+ dropzoneRole: (label) => `Image upload area. ${label}`,
42
+ uploading: 'Uploading…',
43
+ dropReady: 'Image ready to drop',
44
+ percentUploaded: (percent) => `${percent}% uploaded`,
45
+ uploadInProgress: (percent) => `Upload in progress: ${percent}%`,
46
+ errorAnnouncement: (message) => `Error: ${message}`,
47
+ },
48
+ };
49
+ }
@@ -1,3 +1,5 @@
1
+ import type { UploadButtonCopy } from '@12-apps/ui/copy';
2
+
1
3
  import { ACCEPTED_CONTENT_TYPES } from '../content-types';
2
4
  import { megabytes } from '../limits';
3
5
 
@@ -109,6 +111,15 @@ export interface WebStorageMessages {
109
111
  fieldLabel: string;
110
112
  fieldHelper: string;
111
113
  fieldRemove: string;
114
+ /**
115
+ * What the dropzone ITSELF says — its hint line, its accessible name, and
116
+ * the three announcements only a screen reader receives. `@12-apps/ui` stopped
117
+ * shipping English defaults for these, so a host that answers `fieldLabel`
118
+ * and nothing else would have a dropzone with no name and a silent progress
119
+ * bar. `buttonLabel` is unused here: `ImageField` passes `fieldLabel` as the
120
+ * override, because the field's own label is the one a form already names.
121
+ */
122
+ field: UploadButtonCopy;
112
123
  }
113
124
 
114
125
  /** Copy that only makes sense with the configured ceiling in it. */
@@ -5,6 +5,7 @@ import { Box } from '@12-apps/ui/mui/Box';
5
5
  import { Stack } from '@12-apps/ui/mui/Stack';
6
6
  import { Text } from '@12-apps/ui/typography/Text';
7
7
  import { Button } from '@12-apps/ui/form/Button';
8
+ import type { UploadButtonCopy } from '@12-apps/ui/copy';
8
9
  import { UploadButton } from '@12-apps/ui/form/UploadButton';
9
10
 
10
11
  import { ACCEPTED_CONTENT_TYPES } from '../content-types';
@@ -38,6 +39,13 @@ export interface ImageFieldProps {
38
39
  label: string;
39
40
  /** The button that clears a chosen image. REQUIRED, for the same reason. */
40
41
  removeLabel: string;
42
+ /**
43
+ * What the dropzone itself says — the hint under the label, its accessible
44
+ * name, and the three announcements only a screen reader receives. REQUIRED:
45
+ * `@12-apps/ui` ships no default copy, so without this the dropzone has no
46
+ * name and a silent progress bar.
47
+ */
48
+ uploadCopy: UploadButtonCopy;
41
49
  helperText?: string;
42
50
  /** Passed through per upload — `{ optimize: false }` for a rendered canvas. */
43
51
  uploadOptions?: UploadOptions;
@@ -92,6 +100,9 @@ export function ImageField(props: BoundImageFieldProps): JSX.Element {
92
100
  ) : null}
93
101
  <UploadButton
94
102
  variant="dropzone"
103
+ copy={props.uploadCopy}
104
+ // The field's own label wins over `uploadCopy.buttonLabel`: a form
105
+ // already names this field, and naming it twice is how the two drift.
95
106
  label={props.label}
96
107
  accept={ACCEPT}
97
108
  disabled={props.disabled || uploader.uploading}
@@ -42,3 +42,5 @@ export {
42
42
  type WebStorageMessages,
43
43
  } from './failures';
44
44
  export { PT_BR_WEB_STORAGE_MESSAGES } from './pt-BR';
45
+ export { EN_US_WEB_STORAGE_MESSAGES } from './en-US';
46
+ export { WEB_STORAGE_MESSAGES } from './locales';
@@ -0,0 +1,18 @@
1
+ import { EN_US_WEB_STORAGE_MESSAGES } from './en-US';
2
+ import type { WebStorageMessageContext, WebStorageMessages } from './failures';
3
+ import { PT_BR_WEB_STORAGE_MESSAGES } from './pt-BR';
4
+
5
+ /**
6
+ * Both languages of the browser half, keyed by tag. Factories, for the reason
7
+ * the server pack's docstring gives: the ceiling is interpolated so copy cannot
8
+ * name a limit the host has since changed.
9
+ *
10
+ * `LocalePack` is mirrored here rather than imported so the package stays
11
+ * liftable into a repo that has never heard of `@12-apps/i18n`.
12
+ */
13
+ type LocalePack<T> = { readonly 'pt-BR': T; readonly 'en-US': T };
14
+
15
+ export const WEB_STORAGE_MESSAGES = {
16
+ 'pt-BR': PT_BR_WEB_STORAGE_MESSAGES,
17
+ 'en-US': EN_US_WEB_STORAGE_MESSAGES,
18
+ } as const satisfies LocalePack<(context: WebStorageMessageContext) => WebStorageMessages>;
@@ -35,5 +35,18 @@ export function PT_BR_WEB_STORAGE_MESSAGES(
35
35
  fieldLabel: 'Foto do produto',
36
36
  fieldHelper: 'A imagem é reduzida no navegador antes do envio.',
37
37
  fieldRemove: 'Remover imagem',
38
+ field: {
39
+ // `buttonLabel` is never read here — `ImageField` overrides it with
40
+ // `fieldLabel`. It is still answered, because the type is the whole of
41
+ // what `UploadButton` may render and a partial answer is not a pack.
42
+ buttonLabel: 'Foto do produto',
43
+ dropzoneHint: 'Solte a imagem aqui ou clique para escolher',
44
+ dropzoneRole: (label) => `Área de upload de imagem. ${label}`,
45
+ uploading: 'Enviando…',
46
+ dropReady: 'Imagem pronta para soltar',
47
+ percentUploaded: (percent) => `${percent}% enviado`,
48
+ uploadInProgress: (percent) => `Envio em andamento: ${percent}%`,
49
+ errorAnnouncement: (message) => `Erro: ${message}`,
50
+ },
38
51
  };
39
52
  }