@12-apps/storage 2.0.1 → 2.2.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 +9 -5
- package/src/hono/index.ts +8 -3
- package/src/index.ts +1 -1
- package/src/manifest/index.ts +34 -0
- package/src/manifest/server.ts +95 -0
- package/src/problems.ts +0 -23
- package/src/pt-BR.ts +30 -0
- package/src/react/create-web-storage.tsx +14 -14
- package/src/react/failures.ts +30 -39
- package/src/react/index.ts +1 -1
- package/src/react/pt-BR.ts +38 -0
- package/src/react/use-upload.ts +6 -2
- package/src/server/create-api-storage.ts +9 -7
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/storage",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.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": {
|
|
7
7
|
".": "./src/index.ts",
|
|
8
|
-
"./server": "./src/server/index.ts",
|
|
9
|
-
"./react": "./src/react/index.ts",
|
|
10
8
|
"./hono": "./src/hono/index.ts",
|
|
9
|
+
"./manifest": "./src/manifest/index.ts",
|
|
10
|
+
"./manifest/server": "./src/manifest/server.ts",
|
|
11
|
+
"./package.json": "./package.json",
|
|
12
|
+
"./react": "./src/react/index.ts",
|
|
11
13
|
"./s3": "./src/s3/index.ts",
|
|
12
|
-
"./
|
|
14
|
+
"./server": "./src/server/index.ts"
|
|
13
15
|
},
|
|
14
16
|
"scripts": {
|
|
15
17
|
"clean": "rm -rf node_modules coverage",
|
|
@@ -20,6 +22,7 @@
|
|
|
20
22
|
"typecheck": "tsc --noEmit"
|
|
21
23
|
},
|
|
22
24
|
"peerDependencies": {
|
|
25
|
+
"@12-apps/wiring": ">=1.9.0",
|
|
23
26
|
"@aws-sdk/client-s3": ">=3.700.0",
|
|
24
27
|
"hono": ">=4.0.0",
|
|
25
28
|
"react": ">=19.0.0",
|
|
@@ -40,12 +43,13 @@
|
|
|
40
43
|
}
|
|
41
44
|
},
|
|
42
45
|
"dependencies": {
|
|
43
|
-
"@12-apps/ui": "^6.1
|
|
46
|
+
"@12-apps/ui": "^6.3.1",
|
|
44
47
|
"zod": "^4.3.5"
|
|
45
48
|
},
|
|
46
49
|
"devDependencies": {
|
|
47
50
|
"@12-apps/eslint-config": "^1.21.1",
|
|
48
51
|
"@12-apps/typescript-config": "^1.20.1",
|
|
52
|
+
"@12-apps/wiring": "^1.10.0",
|
|
49
53
|
"@aws-sdk/client-s3": "^3.703.0",
|
|
50
54
|
"@testing-library/react": "^16.1.0",
|
|
51
55
|
"@types/react": "19.2.2",
|
package/src/hono/index.ts
CHANGED
|
@@ -38,8 +38,13 @@ export type ResolveStorageActor = (
|
|
|
38
38
|
|
|
39
39
|
export interface StorageHonoConfig extends ApiStorageConfig {
|
|
40
40
|
resolveActor: ResolveStorageActor;
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The 401 body's sentence when `resolveActor` answers null — REQUIRED, the
|
|
43
|
+
* host's words (pt-BR hosts: `PT_BR_STORAGE_UNAUTHENTICATED` from
|
|
44
|
+
* `../pt-BR`). The old default was the same silent leak every other surface
|
|
45
|
+
* here just paid down.
|
|
46
|
+
*/
|
|
47
|
+
unauthenticatedMessage: string;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export interface StorageHono extends ApiStorage {
|
|
@@ -67,7 +72,7 @@ function send(c: Context, response: StorageRouteResponse): Response {
|
|
|
67
72
|
export function storageRouter(config: StorageHonoConfig): StorageHono {
|
|
68
73
|
const { resolveActor, unauthenticatedMessage, ...apiConfig } = config;
|
|
69
74
|
const api = createApiStorage(apiConfig);
|
|
70
|
-
const unauthenticated = unauthenticatedMessage
|
|
75
|
+
const unauthenticated = unauthenticatedMessage;
|
|
71
76
|
const router = new Hono();
|
|
72
77
|
|
|
73
78
|
for (const route of api.routes) {
|
package/src/index.ts
CHANGED
|
@@ -70,12 +70,12 @@ export {
|
|
|
70
70
|
STORAGE_PROBLEM_STATUS,
|
|
71
71
|
StorageNotConfiguredError,
|
|
72
72
|
StorageProblemError,
|
|
73
|
-
defaultStorageMessages,
|
|
74
73
|
isStorageProblem,
|
|
75
74
|
type StorageMessageContext,
|
|
76
75
|
type StorageMessages,
|
|
77
76
|
type StorageProblem,
|
|
78
77
|
} from './problems';
|
|
78
|
+
export { PT_BR_STORAGE_MESSAGES, PT_BR_STORAGE_UNAUTHENTICATED } from './pt-BR';
|
|
79
79
|
|
|
80
80
|
export {
|
|
81
81
|
imageSources,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/storage/manifest` — the SHARED wiring manifest.
|
|
3
|
+
*
|
|
4
|
+
* Identity and the runtime inventory. Three absences are deliberate rather
|
|
5
|
+
* than unfinished:
|
|
6
|
+
*
|
|
7
|
+
* - **No `db`.** This package owns no table. What an uploaded object leaves
|
|
8
|
+
* behind is a KEY, and the row that key hangs off is the host's own — a
|
|
9
|
+
* product image, a store logo — so there is nothing here to compose.
|
|
10
|
+
* - **No `env`.** `./server`'s own header states the rule this manifest would
|
|
11
|
+
* otherwise have to restate: there are no `process.env` reads in shipped
|
|
12
|
+
* source, because every deployment-shaped decision (the driver, the byte
|
|
13
|
+
* ceiling, the key prefix, the bucket) is an ARGUMENT. A package that reads
|
|
14
|
+
* the environment decides for its host; this one asks.
|
|
15
|
+
* - **No `web` inventory.** The React half is components a host renders, not
|
|
16
|
+
* a mountable surface with its own routes.
|
|
17
|
+
*
|
|
18
|
+
* `observability` is mandatory here because `http` is: a refused upload or a
|
|
19
|
+
* driver that could not be reached files under `storage` rather than under
|
|
20
|
+
* whichever host happened to mount it.
|
|
21
|
+
*
|
|
22
|
+
* `@12-apps/wiring` is a TYPE-ONLY devDependency (the report-builder move):
|
|
23
|
+
* the manifests are plain `satisfies`-checked values, and the producer
|
|
24
|
+
* factories' runtime assertions run in this package's own test suite.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { PackageManifest } from '@12-apps/wiring';
|
|
28
|
+
|
|
29
|
+
export const storageManifest = {
|
|
30
|
+
name: '@12-apps/storage',
|
|
31
|
+
contract: 1,
|
|
32
|
+
observability: { namespace: 'storage' },
|
|
33
|
+
server: ['http'],
|
|
34
|
+
} as const satisfies PackageManifest;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/storage/manifest/server` — the server capabilities.
|
|
3
|
+
*
|
|
4
|
+
* `http.create` wraps `createApiStorage` in a WIRE VIEW that finally makes the
|
|
5
|
+
* response rendering the PACKAGE's. `StorageRouteResponse` is a three-way
|
|
6
|
+
* union — a JSON answer, an object's bytes, or a redirect to where the object
|
|
7
|
+
* lives — and until now every host turned those three arms into a `Response`
|
|
8
|
+
* itself. That is mechanism, identical for every host, and one arm of it is
|
|
9
|
+
* a security detail rather than a formatting one:
|
|
10
|
+
*
|
|
11
|
+
* const body = response.bytes.slice().buffer;
|
|
12
|
+
*
|
|
13
|
+
* The `.slice()` is load-bearing. A `Uint8Array` from Node is a view over a
|
|
14
|
+
* POOLED allocation, so handing `.buffer` straight to `Response` serves the
|
|
15
|
+
* client whatever else happens to share that pool. A host that re-derives this
|
|
16
|
+
* renderer has to know that; a host that binds this manifest does not.
|
|
17
|
+
*
|
|
18
|
+
* The bytes and redirect arms therefore answer the contract's RAW half
|
|
19
|
+
* (`{ response }`), which wiring 1.9.0 added for exactly this: answers
|
|
20
|
+
* `{ status, body }` cannot express — a stream, a redirect whose headers are
|
|
21
|
+
* the payload, bytes with their own content type.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { AnyServerManifest, WireRequest, WireRouteAnswer } from '@12-apps/wiring';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
createApiStorage,
|
|
28
|
+
type ApiStorage,
|
|
29
|
+
type ApiStorageConfig,
|
|
30
|
+
type StorageRoute,
|
|
31
|
+
type StorageRouteResponse,
|
|
32
|
+
} from '../server';
|
|
33
|
+
|
|
34
|
+
/** The descriptor's chosen answer, as the wiring contract carries it. */
|
|
35
|
+
export function asWireAnswer(response: StorageRouteResponse): WireRouteAnswer {
|
|
36
|
+
if ('redirect' in response) {
|
|
37
|
+
return { response: Response.redirect(response.redirect, 302) };
|
|
38
|
+
}
|
|
39
|
+
if ('bytes' in response) {
|
|
40
|
+
// Its OWN ArrayBuffer — see the note above; a view over the pool leaks.
|
|
41
|
+
const body = response.bytes.slice().buffer as ArrayBuffer;
|
|
42
|
+
return {
|
|
43
|
+
response: new Response(body, {
|
|
44
|
+
status: response.status,
|
|
45
|
+
headers: {
|
|
46
|
+
...response.headers,
|
|
47
|
+
'content-length': String(response.bytes.byteLength),
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { status: response.status, body: response.body };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One `StorageRoute` as the wiring contract reads it. */
|
|
56
|
+
function asWireRoute(route: StorageRoute): {
|
|
57
|
+
method: StorageRoute['method'];
|
|
58
|
+
path: string;
|
|
59
|
+
kind: 'authenticated' | 'public';
|
|
60
|
+
handle(request: WireRequest): Promise<WireRouteAnswer>;
|
|
61
|
+
} {
|
|
62
|
+
return {
|
|
63
|
+
method: route.method,
|
|
64
|
+
path: route.path,
|
|
65
|
+
// The descriptor's own `auth` flag IS the contract's route kind: a
|
|
66
|
+
// `public` object read is anonymous by design, and the host's gates read
|
|
67
|
+
// this rather than a second table that could disagree with it.
|
|
68
|
+
kind: route.auth === 'public' ? 'public' : 'authenticated',
|
|
69
|
+
handle: async (request) => {
|
|
70
|
+
if (!request.request) {
|
|
71
|
+
throw new Error('@12-apps/storage needs the raw request — bind an adapter that forwards it.');
|
|
72
|
+
}
|
|
73
|
+
return asWireAnswer(
|
|
74
|
+
await route.handle({
|
|
75
|
+
actor: (request.actor ?? null) as Parameters<StorageRoute['handle']>[0]['actor'],
|
|
76
|
+
params: request.params,
|
|
77
|
+
request: request.request,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `createApiStorage`, its routes re-shaped for the aggregate. */
|
|
85
|
+
export function createWireApiStorage(
|
|
86
|
+
config: ApiStorageConfig,
|
|
87
|
+
): Omit<ApiStorage, 'routes'> & { routes: ReturnType<typeof asWireRoute>[] } {
|
|
88
|
+
const api = createApiStorage(config);
|
|
89
|
+
return { ...api, routes: api.routes.map(asWireRoute) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const storageServerManifest = {
|
|
93
|
+
name: '@12-apps/storage',
|
|
94
|
+
http: { create: createWireApiStorage },
|
|
95
|
+
} as const satisfies AnyServerManifest;
|
package/src/problems.ts
CHANGED
|
@@ -56,29 +56,6 @@ export interface StorageMessageContext {
|
|
|
56
56
|
limit: string;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
* The default pt-BR copy. `limit` is interpolated rather than hard-coded so a
|
|
61
|
-
* host that raises `maxBytes` cannot end up with a message naming the old
|
|
62
|
-
* number — the single commonest way this kind of copy goes stale.
|
|
63
|
-
*/
|
|
64
|
-
export function defaultStorageMessages(context: StorageMessageContext): StorageMessages {
|
|
65
|
-
return {
|
|
66
|
-
invalid_base64: 'A imagem enviada não está codificada corretamente em base64.',
|
|
67
|
-
empty_file: 'A imagem enviada está vazia.',
|
|
68
|
-
file_too_large: `A imagem enviada é maior que o limite de ${context.limit}.`,
|
|
69
|
-
content_mismatch:
|
|
70
|
-
'O conteúdo da imagem não corresponde ao formato informado. Envie um PNG, JPEG, WebP ou GIF.',
|
|
71
|
-
unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
|
|
72
|
-
image_unreadable:
|
|
73
|
-
'Não foi possível ler a imagem enviada — o arquivo parece estar corrompido.',
|
|
74
|
-
image_dimensions_too_large:
|
|
75
|
-
'A imagem enviada tem dimensões grandes demais para ser processada.',
|
|
76
|
-
storage_not_configured: 'O armazenamento de imagens ainda não foi configurado nesta loja.',
|
|
77
|
-
storage_unavailable:
|
|
78
|
-
'Não foi possível salvar a imagem agora. Tente novamente em instantes.',
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
59
|
/**
|
|
83
60
|
* A refusal, carrying the status and the finished sentence.
|
|
84
61
|
*
|
package/src/pt-BR.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { StorageMessageContext, StorageMessages } from './problems';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pt-BR pack — a NAMED factory a host passes by hand
|
|
5
|
+
* (`messages: PT_BR_STORAGE_MESSAGES`), never a default. The filename is what
|
|
6
|
+
* exempts this file from the copy-portability gate: Portuguese may ship, it
|
|
7
|
+
* may not be silent. `limit` is interpolated rather than hard-coded so a
|
|
8
|
+
* host that raises `maxBytes` cannot end up with a message naming the old
|
|
9
|
+
* number — the single commonest way this kind of copy goes stale.
|
|
10
|
+
*/
|
|
11
|
+
export function PT_BR_STORAGE_MESSAGES(context: StorageMessageContext): StorageMessages {
|
|
12
|
+
return {
|
|
13
|
+
invalid_base64: 'A imagem enviada não está codificada corretamente em base64.',
|
|
14
|
+
empty_file: 'A imagem enviada está vazia.',
|
|
15
|
+
file_too_large: `A imagem enviada é maior que o limite de ${context.limit}.`,
|
|
16
|
+
content_mismatch:
|
|
17
|
+
'O conteúdo da imagem não corresponde ao formato informado. Envie um PNG, JPEG, WebP ou GIF.',
|
|
18
|
+
unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
|
|
19
|
+
image_unreadable:
|
|
20
|
+
'Não foi possível ler a imagem enviada — o arquivo parece estar corrompido.',
|
|
21
|
+
image_dimensions_too_large:
|
|
22
|
+
'A imagem enviada tem dimensões grandes demais para ser processada.',
|
|
23
|
+
storage_not_configured: 'O armazenamento de imagens ainda não foi configurado nesta loja.',
|
|
24
|
+
storage_unavailable:
|
|
25
|
+
'Não foi possível salvar a imagem agora. Tente novamente em instantes.',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The 401 the mounted router answers when the host resolves no actor. */
|
|
30
|
+
export const PT_BR_STORAGE_UNAUTHENTICATED = 'Não autenticado.';
|
|
@@ -4,7 +4,7 @@ import { Stack } from '@12-apps/ui/mui/Stack';
|
|
|
4
4
|
import { Text } from '@12-apps/ui/typography/Text';
|
|
5
5
|
|
|
6
6
|
import { DEFAULT_MAX_UPLOAD_BYTES, megabytes } from '../limits';
|
|
7
|
-
import type { WebStorageMessages } from './failures';
|
|
7
|
+
import type { WebStorageMessageContext, WebStorageMessages } from './failures';
|
|
8
8
|
import { ImageField, type ImageFieldProps } from './image-field';
|
|
9
9
|
import type { ImageProfile } from './optimize-image';
|
|
10
10
|
import { useUpload, type UploadState, type UseUploadConfig } from './use-upload';
|
|
@@ -33,13 +33,14 @@ export interface WebStorageConfig {
|
|
|
33
33
|
/** How requests are made. Default: same-origin `fetch`. */
|
|
34
34
|
fetchImpl?: typeof fetch;
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
36
|
+
* Every sentence this surface renders — REQUIRED, the host's own factory of
|
|
37
|
+
* the mount's ceiling, mirroring the server mount's `messages` (pt-BR
|
|
38
|
+
* hosts: `PT_BR_WEB_STORAGE_MESSAGES` from `./pt-BR`). Write `forbidden` to
|
|
39
|
+
* name YOUR reason: the package states only that the account may not
|
|
40
|
+
* upload, because `mayUpload` is one host-computed boolean and a package
|
|
41
|
+
* that named a role model would be wrong for every host but the first.
|
|
41
42
|
*/
|
|
42
|
-
messages
|
|
43
|
+
messages: (context: WebStorageMessageContext) => WebStorageMessages;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export interface WebStorage {
|
|
@@ -60,7 +61,7 @@ export function createWebStorage(config: WebStorageConfig): WebStorage {
|
|
|
60
61
|
maxBytes,
|
|
61
62
|
...(config.profile ? { profile: config.profile } : {}),
|
|
62
63
|
...(config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}),
|
|
63
|
-
|
|
64
|
+
messages: config.messages,
|
|
64
65
|
};
|
|
65
66
|
const useBoundUpload = (): UploadState => useUpload(uploadConfig);
|
|
66
67
|
|
|
@@ -75,17 +76,16 @@ export function createWebStorage(config: WebStorageConfig): WebStorage {
|
|
|
75
76
|
*/
|
|
76
77
|
function UploadPage(): JSX.Element {
|
|
77
78
|
const [imageKey, setImageKey] = useState<string | null>(null);
|
|
79
|
+
const messages = config.messages({ limit: megabytes(maxBytes) });
|
|
78
80
|
return (
|
|
79
81
|
<Stack spacing={2} data-testid="storage-upload-page">
|
|
80
|
-
<Text as="h2">
|
|
81
|
-
<Text as="p">
|
|
82
|
-
{`Envie um PNG, JPG, WebP ou GIF de até ${megabytes(maxBytes)}.`}
|
|
83
|
-
</Text>
|
|
82
|
+
<Text as="h2">{messages.pageTitle}</Text>
|
|
83
|
+
<Text as="p">{messages.pageIntro}</Text>
|
|
84
84
|
<BoundImageField
|
|
85
85
|
value={imageKey}
|
|
86
86
|
onChange={setImageKey}
|
|
87
|
-
label=
|
|
88
|
-
helperText=
|
|
87
|
+
label={messages.fieldLabel}
|
|
88
|
+
helperText={messages.fieldHelper}
|
|
89
89
|
/>
|
|
90
90
|
</Stack>
|
|
91
91
|
);
|
package/src/react/failures.ts
CHANGED
|
@@ -88,8 +88,25 @@ export interface WebStorageMessages {
|
|
|
88
88
|
unsupported_content_type_upfront: (context: { contentType: string }) => string;
|
|
89
89
|
/** 2xx with no key in the body — accepted, and unusable. */
|
|
90
90
|
missing_key: string;
|
|
91
|
+
/**
|
|
92
|
+
* A refusal whose body carried no sentence and no code this table knows —
|
|
93
|
+
* the status (and the raw code, when there was one) ride along, because
|
|
94
|
+
* they are what turns "it failed" into something greppable.
|
|
95
|
+
*/
|
|
96
|
+
upload_failed_http: (context: { status: number; detail: string }) => string;
|
|
91
97
|
/** Anything thrown on the way. The last resort, and it says so. */
|
|
92
98
|
upload_failed: string;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The standalone upload page's own chrome — heading, the one-line
|
|
102
|
+
* instruction (which carries the ceiling, same rule as the refusals), the
|
|
103
|
+
* field label and its helper. These rendered as literals inside
|
|
104
|
+
* `createWebStorage` until copy became required config.
|
|
105
|
+
*/
|
|
106
|
+
pageTitle: string;
|
|
107
|
+
pageIntro: string;
|
|
108
|
+
fieldLabel: string;
|
|
109
|
+
fieldHelper: string;
|
|
93
110
|
}
|
|
94
111
|
|
|
95
112
|
/** Copy that only makes sense with the configured ceiling in it. */
|
|
@@ -98,34 +115,6 @@ export interface WebStorageMessageContext {
|
|
|
98
115
|
limit: string;
|
|
99
116
|
}
|
|
100
117
|
|
|
101
|
-
/**
|
|
102
|
-
* The default pt-BR copy. `limit` is interpolated rather than hard-coded for the
|
|
103
|
-
* same reason the server does it: a host that raises the ceiling must not end up
|
|
104
|
-
* with a message naming the old number.
|
|
105
|
-
*/
|
|
106
|
-
export function defaultWebStorageMessages(
|
|
107
|
-
context: WebStorageMessageContext,
|
|
108
|
-
): WebStorageMessages {
|
|
109
|
-
return {
|
|
110
|
-
forbidden: 'Sua conta não tem permissão para enviar imagens nesta loja.',
|
|
111
|
-
unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
|
|
112
|
-
file_too_large: `Imagem muito grande. O limite é ${context.limit}.`,
|
|
113
|
-
invalid_key: 'Não foi possível localizar essa imagem.',
|
|
114
|
-
not_found: 'Essa imagem não está mais disponível.',
|
|
115
|
-
session_expired: 'Sua sessão expirou. Entre novamente e repita o envio.',
|
|
116
|
-
transport: 'Não foi possível falar com o servidor. Verifique sua conexão e tente de novo.',
|
|
117
|
-
empty_file: 'Arquivo vazio (0 bytes). Escolha a imagem novamente.',
|
|
118
|
-
file_too_large_upfront: ({ size, limit }) =>
|
|
119
|
-
`Imagem muito grande: ${size}. O limite é ${limit} — reduza a imagem e tente de novo.`,
|
|
120
|
-
unknown_content_type:
|
|
121
|
-
'Não foi possível identificar o tipo do arquivo. Envie um PNG, JPG ou WebP.',
|
|
122
|
-
unsupported_content_type_upfront: ({ contentType }) =>
|
|
123
|
-
`Formato não suportado (${contentType}). Envie PNG, JPG, WebP ou GIF.`,
|
|
124
|
-
missing_key: 'O servidor aceitou a imagem mas não devolveu a chave. Tente de novo.',
|
|
125
|
-
upload_failed: 'Falha ao enviar a imagem. Tente de novo em instantes.',
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
118
|
/**
|
|
130
119
|
* The messages in force for a mount's ceiling.
|
|
131
120
|
*
|
|
@@ -135,9 +124,9 @@ export function defaultWebStorageMessages(
|
|
|
135
124
|
*/
|
|
136
125
|
export function messagesOf(
|
|
137
126
|
maxBytes: number,
|
|
138
|
-
|
|
127
|
+
factory: (context: WebStorageMessageContext) => WebStorageMessages,
|
|
139
128
|
): WebStorageMessages {
|
|
140
|
-
return
|
|
129
|
+
return factory({ limit: megabytes(maxBytes) });
|
|
141
130
|
}
|
|
142
131
|
|
|
143
132
|
const resolve = messagesOf;
|
|
@@ -173,9 +162,9 @@ function sentenceFor(messages: WebStorageMessages, code: string): string | undef
|
|
|
173
162
|
export function rejectFileUpfront(
|
|
174
163
|
file: File,
|
|
175
164
|
maxBytes: number,
|
|
176
|
-
|
|
165
|
+
factory: (context: WebStorageMessageContext) => WebStorageMessages,
|
|
177
166
|
): string | null {
|
|
178
|
-
const messages = resolve(maxBytes,
|
|
167
|
+
const messages = resolve(maxBytes, factory);
|
|
179
168
|
if (file.size === 0) {
|
|
180
169
|
return messages.empty_file;
|
|
181
170
|
}
|
|
@@ -230,9 +219,9 @@ async function statedError(response: Response): Promise<string | null> {
|
|
|
230
219
|
export async function uploadFailure(
|
|
231
220
|
response: Response,
|
|
232
221
|
maxBytes: number,
|
|
233
|
-
|
|
222
|
+
factory: (context: WebStorageMessageContext) => WebStorageMessages,
|
|
234
223
|
): Promise<string> {
|
|
235
|
-
const messages = resolve(maxBytes,
|
|
224
|
+
const messages = resolve(maxBytes, factory);
|
|
236
225
|
if (response.status === 401) return messages.session_expired;
|
|
237
226
|
const stated = await statedError(response);
|
|
238
227
|
// A sentence rather than a code: the server already phrased it for the owner,
|
|
@@ -245,7 +234,7 @@ export async function uploadFailure(
|
|
|
245
234
|
// what turns a support request from "it failed" into something greppable — so it
|
|
246
235
|
// rides along with the status rather than being mapped away.
|
|
247
236
|
const detail = stated ? ` (${stated})` : '';
|
|
248
|
-
return
|
|
237
|
+
return messages.upload_failed_http({ status: response.status, detail });
|
|
249
238
|
}
|
|
250
239
|
|
|
251
240
|
/**
|
|
@@ -254,8 +243,10 @@ export async function uploadFailure(
|
|
|
254
243
|
* One sentence, and it can afford to be definite: the upload goes to our own
|
|
255
244
|
* origin, so there is no cross-origin case to hedge about.
|
|
256
245
|
*/
|
|
257
|
-
export function transportFailure(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
246
|
+
export function transportFailure(
|
|
247
|
+
factory: (context: WebStorageMessageContext) => WebStorageMessages,
|
|
248
|
+
): string {
|
|
249
|
+
// No ceiling to interpolate on this one, so the factory is read without a
|
|
250
|
+
// limit rather than through a table built from a number that would be a lie.
|
|
251
|
+
return factory({ limit: '' }).transport;
|
|
261
252
|
}
|
package/src/react/index.ts
CHANGED
|
@@ -35,10 +35,10 @@ export {
|
|
|
35
35
|
} from './optimize-image';
|
|
36
36
|
|
|
37
37
|
export {
|
|
38
|
-
defaultWebStorageMessages,
|
|
39
38
|
rejectFileUpfront,
|
|
40
39
|
transportFailure,
|
|
41
40
|
uploadFailure,
|
|
42
41
|
type WebStorageMessageContext,
|
|
43
42
|
type WebStorageMessages,
|
|
44
43
|
} from './failures';
|
|
44
|
+
export { PT_BR_WEB_STORAGE_MESSAGES } from './pt-BR';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { WebStorageMessageContext, WebStorageMessages } from './failures';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pt-BR pack — a NAMED factory a host passes by hand, never a default.
|
|
5
|
+
* `limit` is interpolated rather than hard-coded for the same reason the
|
|
6
|
+
* server pack does it: a host that raises the ceiling must not end up with
|
|
7
|
+
* copy naming the old number. The filename is what exempts this file from
|
|
8
|
+
* the copy-portability gate: Portuguese may ship, it may not be silent.
|
|
9
|
+
*/
|
|
10
|
+
export function PT_BR_WEB_STORAGE_MESSAGES(
|
|
11
|
+
context: WebStorageMessageContext,
|
|
12
|
+
): WebStorageMessages {
|
|
13
|
+
return {
|
|
14
|
+
forbidden: 'Sua conta não tem permissão para enviar imagens nesta loja.',
|
|
15
|
+
unsupported_content_type: 'Formato não suportado. Envie PNG, JPG, WebP ou GIF.',
|
|
16
|
+
file_too_large: `Imagem muito grande. O limite é ${context.limit}.`,
|
|
17
|
+
invalid_key: 'Não foi possível localizar essa imagem.',
|
|
18
|
+
not_found: 'Essa imagem não está mais disponível.',
|
|
19
|
+
session_expired: 'Sua sessão expirou. Entre novamente e repita o envio.',
|
|
20
|
+
transport: 'Não foi possível falar com o servidor. Verifique sua conexão e tente de novo.',
|
|
21
|
+
empty_file: 'Arquivo vazio (0 bytes). Escolha a imagem novamente.',
|
|
22
|
+
file_too_large_upfront: ({ size, limit }) =>
|
|
23
|
+
`Imagem muito grande: ${size}. O limite é ${limit} — reduza a imagem e tente de novo.`,
|
|
24
|
+
unknown_content_type:
|
|
25
|
+
'Não foi possível identificar o tipo do arquivo. Envie um PNG, JPG ou WebP.',
|
|
26
|
+
unsupported_content_type_upfront: ({ contentType }) =>
|
|
27
|
+
`Formato não suportado (${contentType}). Envie PNG, JPG, WebP ou GIF.`,
|
|
28
|
+
missing_key: 'O servidor aceitou a imagem mas não devolveu a chave. Tente de novo.',
|
|
29
|
+
upload_failed_http: ({ status, detail }) =>
|
|
30
|
+
`Não foi possível enviar a imagem (HTTP ${status}${detail}). Tente de novo em instantes.`,
|
|
31
|
+
upload_failed: 'Falha ao enviar a imagem. Tente de novo em instantes.',
|
|
32
|
+
|
|
33
|
+
pageTitle: 'Imagens da loja',
|
|
34
|
+
pageIntro: `Envie um PNG, JPG, WebP ou GIF de até ${context.limit}.`,
|
|
35
|
+
fieldLabel: 'Foto do produto',
|
|
36
|
+
fieldHelper: 'A imagem é reduzida no navegador antes do envio.',
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/react/use-upload.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
rejectFileUpfront,
|
|
7
7
|
transportFailure,
|
|
8
8
|
uploadFailure,
|
|
9
|
+
type WebStorageMessageContext,
|
|
9
10
|
type WebStorageMessages,
|
|
10
11
|
} from './failures';
|
|
11
12
|
import { optimizeImage, type ImageProfile } from './optimize-image';
|
|
@@ -56,8 +57,11 @@ export interface UseUploadConfig {
|
|
|
56
57
|
profile?: ImageProfile;
|
|
57
58
|
/** How the request is made. Default: same-origin `fetch`. */
|
|
58
59
|
fetchImpl?: typeof fetch;
|
|
59
|
-
/**
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The refusal copy, as the host's own factory of the mount's ceiling —
|
|
62
|
+
* REQUIRED (pt-BR hosts: `PT_BR_WEB_STORAGE_MESSAGES` from `./pt-BR`).
|
|
63
|
+
*/
|
|
64
|
+
messages: (context: WebStorageMessageContext) => WebStorageMessages;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
export function useUpload(config: UseUploadConfig): UploadState {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ACCEPTED_CONTENT_TYPES } from '../content-types';
|
|
2
2
|
import { DEFAULT_KEY_PREFIX } from '../keys';
|
|
3
3
|
import { megabytes } from '../limits';
|
|
4
|
-
import {
|
|
4
|
+
import type { StorageMessageContext, StorageMessages } from '../problems';
|
|
5
5
|
import { CATALOG_RENDITIONS, type RenditionSpec } from '../renditions';
|
|
6
6
|
import { imageSources, objectUrl, versionedObjectUrl, type ImageSources } from '../urls';
|
|
7
7
|
import { inlineImageSchema, objectKeySchema, type InlineImage } from '../wire';
|
|
@@ -94,8 +94,13 @@ export interface ApiStorageConfig {
|
|
|
94
94
|
renditions?: readonly RenditionSpec[];
|
|
95
95
|
/** Top-level key segment. Default `products`. */
|
|
96
96
|
keyPrefix?: string;
|
|
97
|
-
/**
|
|
98
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The sentence each refusal is stated in — REQUIRED, the host's words. A
|
|
99
|
+
* factory of the mount's own context so a host that raises `maxBytes`
|
|
100
|
+
* cannot end up with copy naming the old ceiling. pt-BR hosts pass
|
|
101
|
+
* `PT_BR_STORAGE_MESSAGES` from `../pt-BR`.
|
|
102
|
+
*/
|
|
103
|
+
messages: (context: StorageMessageContext) => StorageMessages;
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
/** What the mount reports about itself — the anti-drift surface. */
|
|
@@ -167,10 +172,7 @@ const SILENT: StorageLogger = { error: () => undefined };
|
|
|
167
172
|
export function createApiStorage(config: ApiStorageConfig): ApiStorage {
|
|
168
173
|
const specs = config.renditions ?? CATALOG_RENDITIONS;
|
|
169
174
|
const keyPrefix = config.keyPrefix ?? DEFAULT_KEY_PREFIX;
|
|
170
|
-
const messages: StorageMessages = {
|
|
171
|
-
...defaultStorageMessages({ limit: megabytes(config.maxBytes) }),
|
|
172
|
-
...config.messages,
|
|
173
|
-
};
|
|
175
|
+
const messages: StorageMessages = config.messages({ limit: megabytes(config.maxBytes) });
|
|
174
176
|
const writeDeps: StoreImageDeps = {
|
|
175
177
|
driver: config.driver,
|
|
176
178
|
pipeline: config.imagePipeline,
|