, ... }
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => {
return acc + `${url} ${wDescriptor}` + (i < arr.length - 1 ? ', ' : '')
}, '')
}, [srcSetObj])
const sizes = srcSet ? `${(topLevel ? 100 : 66)}vw` : undefined
// get source url in best resolution
const bestResSrc = useMemo(() => {
if (!srcSetObj) return src
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => {
const w = Number(wDescriptor.replace(/w$/, ''))
return w > acc.w ? { w, url } : acc
}, { w: 0, url: undefined }).url
}, [srcSetObj])
const handleError = useCallback(onError, [onError])
const handleClick = useCallback(() => onClick(bestResSrc), [onClick, bestResSrc])
return (
)
}
const Image = memo(({ className, src, srcSet, sizes, width, height, bestResSrc, onClick, onError }) => {
const style = width && height
? { '--height': `${height}px`, '--width': `${width}px`, '--aspect-ratio': `${width} / ${height}` }
: undefined
return (
)
})
export default function ZoomableImage ({ src, srcSet, ...props }) {
const showModal = useShowModal()
// if `srcSet` is falsy, it means the image was not processed by worker yet
const [imgproxy, setImgproxy] = useState(!!srcSet || IMGPROXY_URL_REGEXP.test(src))
// backwards compatibility:
// src may already be imgproxy url since we used to replace image urls with imgproxy urls
const originalUrl = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
const handleClick = useCallback((src) => showModal(close => {
return (
)
}, {
fullScreen: true,
overflow: (
open original
)
}), [showModal, originalUrl, styles])
const handleError = useCallback(() => setImgproxy(false), [setImgproxy])
if (!src) return null
if (imgproxy) {
return (
)
}
return
}
export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => {
const toaster = useToast()
ref ??= useRef(null)
const [getSignedPOST] = useMutation(
gql`
mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!, $avatar: Boolean) {
getSignedPOST(type: $type, size: $size, width: $width, height: $height, avatar: $avatar) {
url
fields
}
}`)
const s3Upload = useCallback(async file => {
const img = new window.Image()
file = await removeExifData(file)
return new Promise((resolve, reject) => {
img.onload = async () => {
onUpload?.(file)
let data
const variables = {
avatar,
type: file.type,
size: file.size,
width: img.width,
height: img.height
}
try {
({ data } = await getSignedPOST({ variables }))
} catch (e) {
toaster.danger('error initiating upload: ' + e.message || e.toString?.())
onError?.({ ...variables, name: file.name, file })
reject(e)
return
}
const form = new FormData()
Object.keys(data.getSignedPOST.fields).forEach(key => form.append(key, data.getSignedPOST.fields[key]))
form.append('Content-Type', file.type)
form.append('Cache-Control', 'max-age=31536000')
form.append('acl', 'public-read')
form.append('file', file)
const res = await fetch(data.getSignedPOST.url, {
method: 'POST',
body: form
})
if (!res.ok) {
// TODO make sure this is actually a helpful error message and does not expose anything to the user we don't want
const err = res.statusText
toaster.danger('error uploading: ' + err)
onError?.({ ...variables, name: file.name, file })
reject(err)
return
}
const url = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${data.getSignedPOST.fields.key}`
// key is upload id in database
const id = data.getSignedPOST.fields.key
onSuccess?.({ ...variables, id, name: file.name, url, file })
resolve(id)
}
img.onerror = reject
img.src = window.URL.createObjectURL(file)
})
}, [toaster, getSignedPOST])
return (
<>
{
const fileList = e.target.files
for (const file of Array.from(fileList)) {
if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) {
toaster.danger(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
continue
}
if (onSelect) await onSelect?.(file, s3Upload)
else await s3Upload(file)
// reset file input
// see https://bobbyhadz.com/blog/react-reset-file-input#reset-a-file-input-in-react
e.target.value = null
}
}}
/>
ref.current?.click()} style={{ cursor: 'pointer' }} tabIndex={0} onKeyDown={(e) => {
if (e.key === 'Enter') { ref.current?.click() }
}}
>
{children}
>
)
})
// from https://stackoverflow.com/a/77472484
const removeExifData = async (file) => {
if (!file || !file.type.startsWith('image/')) return file
const cleanBuffer = (arrayBuffer) => {
let dataView = new DataView(arrayBuffer)
const exifMarker = 0xffe1
let offset = 2 // Skip the first two bytes (0xFFD8)
while (offset < dataView.byteLength) {
if (dataView.getUint16(offset) === exifMarker) {
// Found an EXIF marker
const segmentLength = dataView.getUint16(offset + 2, false) + 2
arrayBuffer = removeSegment(arrayBuffer, offset, segmentLength)
dataView = new DataView(arrayBuffer)
} else {
// Move to the next marker
offset += 2 + dataView.getUint16(offset + 2, false)
}
}
return arrayBuffer
}
const removeSegment = (buffer, offset, length) => {
// Create a new buffer without the specified segment
const modifiedBuffer = new Uint8Array(buffer.byteLength - length)
modifiedBuffer.set(new Uint8Array(buffer.slice(0, offset)), 0)
modifiedBuffer.set(new Uint8Array(buffer.slice(offset + length)), offset)
return modifiedBuffer.buffer
}
function getOrientation (file) {
const fr = new window.FileReader()
return new Promise((resolve, reject) => {
fr.onload = function () {
const view = new DataView(this.result)
if (view.getUint16(0, false) !== 0xFFD8) {
// not JPEG
return resolve(-2)
}
const length = view.byteLength; let offset = 2
while (offset < length) {
if (view.getUint16(offset + 2, false) <= 8) return resolve(-1) // no orientation available
const marker = view.getUint16(offset, false)
offset += 2
if (marker === 0xFFE1) {
if (view.getUint32(offset += 2, false) !== 0x45786966) {
// no orientation available
return resolve(-1)
}
const little = view.getUint16(offset += 6, false) === 0x4949
offset += view.getUint32(offset + 4, little)
const tags = view.getUint16(offset, little)
offset += 2
for (let i = 0; i < tags; i++) {
if (view.getUint16(offset + (i * 12), little) === 0x0112) {
// orientation available
return resolve(view.getUint16(offset + (i * 12) + 8, little))
}
}
} else if ((marker & 0xFF00) !== 0xFF00) {
break
} else {
offset += view.getUint16(offset, false)
}
}
// no orientation available
return resolve(-1)
}
fr.onerror = reject
fr.readAsArrayBuffer(file)
})
}
const orientation = await getOrientation(file)
const cleanFile = await new Promise((resolve, reject) => {
const fr = new window.FileReader()
fr.onload = function () {
const cleanedBuffer = cleanBuffer(this.result)
const blob = new Blob([cleanedBuffer], { type: file.type })
const newFile = new File([blob], file.name, { type: file.type })
resolve(newFile)
}
fr.onerror = reject
fr.readAsArrayBuffer(file)
})
if (orientation <= 0) {
// not orientation available (-1) or not JPEG (-2)
return cleanFile
}
// put orientation value back in
return new Promise((resolve, reject) => {
const fr = new window.FileReader()
fr.onload = function () {
const zeroth = {}
// Orientation is of type SHORT so single int is ok, see https://piexifjs.readthedocs.io/en/latest/appendices.html
zeroth[piexif.ImageIFD.Orientation] = orientation
const exifObj = { '0th': zeroth }
const exifStr = piexif.dump(exifObj)
const inserted = piexif.insert(exifStr, this.result)
const dataUriToBuffer = (dataUri) => {
// data-uri scheme regexp from https://github.com/ragingwind/data-uri-regex/blob/a9d7474c833e8fbf5b1821fe65d8cccd6aea4536/index.js
// data:[][;charset=][;base64],
const regexp = /^(data:)([\w/+-]*)(;charset=[\w-]+|;base64){0,1},(.*)/gi
const b64 = regexp.exec(dataUri)[4]
const buf = Buffer.from(b64, 'base64')
return buf
}
const buf = dataUriToBuffer(inserted)
const blob = new Blob([buf], { type: file.type })
const newFile = new File([blob], file.name, { type: file.type })
resolve(newFile)
}
fr.onerror = reject
// piexifjs library needs data URI as input
fr.readAsDataURL(cleanFile)
})
}