enh
This commit is contained in:
parent
15342a09c2
commit
f6bb071d7c
6 changed files with 59 additions and 26 deletions
|
|
@ -5,6 +5,15 @@ import ProductForm from '@/src/components/ProductForm'
|
||||||
import { trpc } from '@/src/trpc-client'
|
import { trpc } from '@/src/trpc-client'
|
||||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore'
|
||||||
|
|
||||||
|
// SKUs store bucket-relative object keys, not URLs. Drop the query string and
|
||||||
|
// keep the last two path segments:
|
||||||
|
// https://<acct>.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-...
|
||||||
|
// -> product-images/1.jpg
|
||||||
|
const toImageKey = (url: string): string => {
|
||||||
|
const parts = url.split('?')[0].split('/').filter(Boolean)
|
||||||
|
return parts.slice(-2).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
export default function AddProduct() {
|
export default function AddProduct() {
|
||||||
const createProduct = trpc.admin.product.createProduct.useMutation()
|
const createProduct = trpc.admin.product.createProduct.useMutation()
|
||||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||||
|
|
@ -63,7 +72,8 @@ export default function AddProduct() {
|
||||||
let urlCursor = 0
|
let urlCursor = 0
|
||||||
const skus = values.variants.map((variant: any, vIndex: number) => {
|
const skus = values.variants.map((variant: any, vIndex: number) => {
|
||||||
const count = imageCounts[vIndex]
|
const count = imageCounts[vIndex]
|
||||||
const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
|
// Send keys, not the presigned URLs.
|
||||||
|
const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count).map(toImageKey)
|
||||||
urlCursor += count
|
urlCursor += count
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStore';
|
||||||
|
|
||||||
|
// SKUs store bucket-relative object keys, not URLs. Drop the query string and
|
||||||
|
// keep the last two path segments:
|
||||||
|
// https://<acct>.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-...
|
||||||
|
// -> product-images/1.jpg
|
||||||
|
const toImageKey = (url: string): string => {
|
||||||
|
const parts = url.split('?')[0].split('/').filter(Boolean)
|
||||||
|
return parts.slice(-2).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
export default function EditProduct() {
|
export default function EditProduct() {
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
const productId = Number(id);
|
const productId = Number(id);
|
||||||
|
|
@ -140,9 +149,10 @@ export default function EditProduct() {
|
||||||
// Existing images (mimeType === null) stay as-is
|
// Existing images (mimeType === null) stay as-is
|
||||||
const existingUrls = variantImages[vIndex]
|
const existingUrls = variantImages[vIndex]
|
||||||
?.filter((img) => img.mimeType === null)
|
?.filter((img) => img.mimeType === null)
|
||||||
.map((img) => img.url) || []
|
.map((img) => toImageKey(img.url)) || []
|
||||||
|
|
||||||
const allUrls = [...existingUrls, ...newUrls]
|
// Send keys, not URLs — the SKU stores the bucket-relative object key.
|
||||||
|
const allUrls = [...existingUrls, ...newUrls.map(toImageKey)]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: variant.id,
|
id: variant.id,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ export function useUploadToObjectStorage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const body = await response.text(); // <Error><Code>…</Code></Error>
|
||||||
|
console.error('upload failed', response.status, body);
|
||||||
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
|
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,10 +118,14 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = getAwsClient()
|
const client = getAwsClient()
|
||||||
const url = buildObjectUrl(getS3BucketName(), s3Url)
|
// X-Amz-Expires must be a QUERY param, not a header: with signQuery, aws4fetch
|
||||||
const signedRequest = await client.sign(url, {
|
// hoists header values into the query but still lists them in
|
||||||
|
// X-Amz-SignedHeaders, and R2 then hashes the missing header as empty — which
|
||||||
|
// makes the signature mismatch (S3 tolerates it, R2 does not).
|
||||||
|
const url = new URL(buildObjectUrl(getS3BucketName(), s3Url))
|
||||||
|
url.searchParams.set('X-Amz-Expires', String(expiresIn))
|
||||||
|
const signedRequest = await client.sign(url.toString(), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'X-Amz-Expires': String(expiresIn) },
|
|
||||||
aws: { signQuery: true },
|
aws: { signQuery: true },
|
||||||
})
|
})
|
||||||
return signedRequest.url
|
return signedRequest.url
|
||||||
|
|
@ -170,13 +174,13 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
|
||||||
await createUploadUrlStatus(key)
|
await createUploadUrlStatus(key)
|
||||||
|
|
||||||
const client = getAwsClient()
|
const client = getAwsClient()
|
||||||
const url = buildObjectUrl(getS3BucketName(), key)
|
// Same as the GET path above: keep X-Amz-Expires in the query, and do not
|
||||||
const signedRequest = await client.sign(url, {
|
// sign Content-Type — leaving it unsigned means the client may send any value
|
||||||
|
// without breaking the signature.
|
||||||
|
const url = new URL(buildObjectUrl(getS3BucketName(), key))
|
||||||
|
url.searchParams.set('X-Amz-Expires', String(expiresIn))
|
||||||
|
const signedRequest = await client.sign(url.toString(), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
|
||||||
'Content-Type': mimeType,
|
|
||||||
'X-Amz-Expires': String(expiresIn),
|
|
||||||
},
|
|
||||||
aws: { signQuery: true },
|
aws: { signQuery: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -193,15 +197,22 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
|
||||||
// return decodeURIComponent(rawKey);
|
// return decodeURIComponent(rawKey);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// New function (excludes bucket name)
|
/**
|
||||||
|
* Normalise an image reference to the bucket-relative object key that SKUs store.
|
||||||
|
*
|
||||||
|
* Accepts either a full (presigned) URL or an already-extracted key, so callers
|
||||||
|
* can send whichever they have. Drops the query string, then keeps the last two
|
||||||
|
* path segments:
|
||||||
|
*
|
||||||
|
* https://<account>.r2.cloudflarestorage.com/meatfarmer-dev/product-images/1.jpg?X-Amz-...
|
||||||
|
* -> product-images/1.jpg
|
||||||
|
* product-images/1.jpg
|
||||||
|
* -> product-images/1.jpg
|
||||||
|
*/
|
||||||
export function extractKeyFromPresignedUrl(url: string): string {
|
export function extractKeyFromPresignedUrl(url: string): string {
|
||||||
const u = new URL(url)
|
const withoutQuery = url.split('?')[0]
|
||||||
const rawKey = u.pathname.replace(/^\/+/, '') // remove leading slash
|
const parts = withoutQuery.split('/').filter(Boolean)
|
||||||
const decodedKey = decodeURIComponent(rawKey)
|
return decodeURIComponent(parts.slice(-2).join('/'))
|
||||||
// Remove bucket prefix
|
|
||||||
const parts = decodedKey.split('/')
|
|
||||||
parts.shift() // Remove bucket name
|
|
||||||
return parts.join('/')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function claimUploadUrl(url: string): Promise<void> {
|
export async function claimUploadUrl(url: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -260,8 +260,6 @@ export const productRouter = router({
|
||||||
await deleteImageUtil({ keys: deletedImageKeys })
|
await deleteImageUtil({ keys: deletedImageKeys })
|
||||||
}
|
}
|
||||||
|
|
||||||
const allUploadUrls: string[] = skus.flatMap((sku) => sku.images)
|
|
||||||
|
|
||||||
const skuInputs = skus.map((sku) => ({
|
const skuInputs = skus.map((sku) => ({
|
||||||
id: sku.id,
|
id: sku.id,
|
||||||
name: sku.name ?? null,
|
name: sku.name ?? null,
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@ import usePickImage from './use-pick-image'
|
||||||
|
|
||||||
export type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from '@packages/shared'
|
export type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from '@packages/shared'
|
||||||
|
|
||||||
const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
|
const ImageUploaderNeo: React.FC<ImageUploaderNeoProps & { testID?: string }> = ({
|
||||||
images,
|
images,
|
||||||
onImageAdd,
|
onImageAdd,
|
||||||
onImageRemove,
|
onImageRemove,
|
||||||
allowMultiple = true,
|
allowMultiple = true,
|
||||||
|
testID,
|
||||||
}) => {
|
}) => {
|
||||||
const totalImageCount = images.length
|
const totalImageCount = images.length
|
||||||
|
|
||||||
|
|
@ -60,6 +61,7 @@ const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
<MyTouchableOpacity
|
<MyTouchableOpacity
|
||||||
|
testID={testID}
|
||||||
disabled={!allowMultiple && totalImageCount >= 1}
|
disabled={!allowMultiple && totalImageCount >= 1}
|
||||||
onPress={handlePickImage}
|
onPress={handlePickImage}
|
||||||
style={tw`w-1/3 px-1 mb-2`}
|
style={tw`w-1/3 px-1 mb-2`}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue