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 { 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() {
|
||||
const createProduct = trpc.admin.product.createProduct.useMutation()
|
||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||
|
|
@ -63,7 +72,8 @@ export default function AddProduct() {
|
|||
let urlCursor = 0
|
||||
const skus = values.variants.map((variant: any, vIndex: number) => {
|
||||
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
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@ import ProductForm, { ProductFormRef } from '@/src/components/ProductForm';
|
|||
import { trpc } from '@/src/trpc-client';
|
||||
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() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const productId = Number(id);
|
||||
|
|
@ -140,9 +149,10 @@ export default function EditProduct() {
|
|||
// Existing images (mimeType === null) stay as-is
|
||||
const existingUrls = variantImages[vIndex]
|
||||
?.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 {
|
||||
id: variant.id,
|
||||
|
|
|
|||
|
|
@ -43,9 +43,11 @@ export function useUploadToObjectStorage() {
|
|||
headers: { 'Content-Type': mimeType },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload ${index + 1} failed with status ${response.status}`);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
setProgress(prev => prev ? { ...prev, completed: prev.completed + 1 } : null);
|
||||
|
|
|
|||
|
|
@ -118,10 +118,14 @@ export async function generateSignedUrlFromS3Url(s3UrlRaw: string|null, expiresI
|
|||
|
||||
try {
|
||||
const client = getAwsClient()
|
||||
const url = buildObjectUrl(getS3BucketName(), s3Url)
|
||||
const signedRequest = await client.sign(url, {
|
||||
// X-Amz-Expires must be a QUERY param, not a header: with signQuery, aws4fetch
|
||||
// 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',
|
||||
headers: { 'X-Amz-Expires': String(expiresIn) },
|
||||
aws: { signQuery: true },
|
||||
})
|
||||
return signedRequest.url
|
||||
|
|
@ -170,13 +174,13 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
|
|||
await createUploadUrlStatus(key)
|
||||
|
||||
const client = getAwsClient()
|
||||
const url = buildObjectUrl(getS3BucketName(), key)
|
||||
const signedRequest = await client.sign(url, {
|
||||
// Same as the GET path above: keep X-Amz-Expires in the query, and do not
|
||||
// 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',
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'X-Amz-Expires': String(expiresIn),
|
||||
},
|
||||
aws: { signQuery: true },
|
||||
})
|
||||
|
||||
|
|
@ -193,15 +197,22 @@ export async function generateUploadUrl(key: string, mimeType: string, expiresIn
|
|||
// 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 {
|
||||
const u = new URL(url)
|
||||
const rawKey = u.pathname.replace(/^\/+/, '') // remove leading slash
|
||||
const decodedKey = decodeURIComponent(rawKey)
|
||||
// Remove bucket prefix
|
||||
const parts = decodedKey.split('/')
|
||||
parts.shift() // Remove bucket name
|
||||
return parts.join('/')
|
||||
const withoutQuery = url.split('?')[0]
|
||||
const parts = withoutQuery.split('/').filter(Boolean)
|
||||
return decodeURIComponent(parts.slice(-2).join('/'))
|
||||
}
|
||||
|
||||
export async function claimUploadUrl(url: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -260,8 +260,6 @@ export const productRouter = router({
|
|||
await deleteImageUtil({ keys: deletedImageKeys })
|
||||
}
|
||||
|
||||
const allUploadUrls: string[] = skus.flatMap((sku) => sku.images)
|
||||
|
||||
const skuInputs = skus.map((sku) => ({
|
||||
id: sku.id,
|
||||
name: sku.name ?? null,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import usePickImage from './use-pick-image'
|
|||
|
||||
export type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from '@packages/shared'
|
||||
|
||||
const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
|
||||
const ImageUploaderNeo: React.FC<ImageUploaderNeoProps & { testID?: string }> = ({
|
||||
images,
|
||||
onImageAdd,
|
||||
onImageRemove,
|
||||
allowMultiple = true,
|
||||
testID,
|
||||
}) => {
|
||||
const totalImageCount = images.length
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ const ImageUploaderNeo: React.FC<ImageUploaderNeoProps> = ({
|
|||
</View>
|
||||
))}
|
||||
<MyTouchableOpacity
|
||||
testID={testID}
|
||||
disabled={!allowMultiple && totalImageCount >= 1}
|
||||
onPress={handlePickImage}
|
||||
style={tw`w-1/3 px-1 mb-2`}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue