stacker.news/components/image.js

235 lines
7.6 KiB
JavaScript
Raw Normal View History

2023-10-01 23:03:52 +00:00
import styles from './text.module.css'
Image uploads (#576) * Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 20:53:33 +00:00
import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef } from 'react'
import { IMGPROXY_URL_REGEXP } from '../lib/url'
2023-10-01 23:03:52 +00:00
import { useShowModal } from './modal'
import { useMe } from './me'
import { Dropdown } from 'react-bootstrap'
Image uploads (#576) * Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 20:53:33 +00:00
import { UPLOAD_TYPES_ALLOW } from '../lib/constants'
import { useToast } from './toast'
import gql from 'graphql-tag'
import { useMutation } from '@apollo/client'
2023-10-01 23:03:52 +00:00
export function decodeOriginalUrl (imgproxyUrl) {
const parts = imgproxyUrl.split('/')
// base64url is not a known encoding in browsers
// so we need to replace the invalid chars
const b64Url = parts[parts.length - 1].replace(/-/g, '+').replace(/_/, '/')
const originalUrl = Buffer.from(b64Url, 'base64').toString('utf-8')
return originalUrl
}
function ImageOriginal ({ src, topLevel, nofollow, tab, children, onClick, ...props }) {
2023-10-01 23:03:52 +00:00
const me = useMe()
const [showImage, setShowImage] = useState(false)
2023-10-01 23:03:52 +00:00
useEffect(() => {
if (me?.imgproxyOnly && tab !== 'preview') return
// make sure it's not a false negative by trying to load URL as <img>
const img = new window.Image()
img.onload = () => setShowImage(true)
img.src = src
2023-10-01 23:03:52 +00:00
return () => {
img.onload = null
img.src = ''
2023-10-01 23:03:52 +00:00
}
}, [src, showImage])
2023-10-01 23:03:52 +00:00
if (showImage) {
return (
<img
className={topLevel ? styles.topLevel : undefined}
src={src}
onClick={() => onClick(src)}
onError={() => setShowImage(false)}
/>
)
} else {
// user is not okay with loading original url automatically or there was an error loading the image
// If element parsed by markdown is a raw URL, we use src as the text to not mislead users.
// This will not be the case if [text](url) format is used. Then we will show what was chosen as text.
const isRawURL = /^https?:\/\//.test(children?.[0])
return (
<a
target='_blank'
rel={`noreferrer ${nofollow ? 'nofollow' : ''} noopener`}
href={src}
>{isRawURL ? src : children}
</a>
)
}
2023-10-01 23:03:52 +00:00
}
function ImageProxy ({ src, srcSet: srcSetObj, onClick, topLevel, onError, ...props }) {
2023-10-01 23:03:52 +00:00
const srcSet = useMemo(() => {
if (!srcSetObj) return undefined
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
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
2023-10-01 23:03:52 +00:00
// get source url in best resolution
const bestResSrc = useMemo(() => {
if (!srcSetObj) return src
2023-10-01 23:03:52 +00:00
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])
return (
<img
className={topLevel ? styles.topLevel : undefined}
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
src={bestResSrc}
srcSet={srcSet}
sizes={sizes}
onClick={() => onClick(bestResSrc)}
onError={onError}
/>
)
}
2023-10-01 23:03:52 +00:00
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 (
<div
className={styles.fullScreenContainer}
onClick={close}
>
<img className={styles.fullScreen} src={src} />
</div>
)
}, {
2023-10-01 23:03:52 +00:00
fullScreen: true,
overflow: (
<Dropdown.Item
2023-10-06 20:54:46 +00:00
href={originalUrl} target='_blank'
rel={`noreferrer ${props.nofollow ? 'nofollow' : ''} noopener`}
2023-10-01 23:03:52 +00:00
>
open original
2023-10-01 23:03:52 +00:00
</Dropdown.Item>)
}), [showModal, originalUrl, styles])
2023-10-01 23:03:52 +00:00
if (!src) return null
if (imgproxy) {
2023-10-01 23:03:52 +00:00
return (
<ImageProxy
src={src} srcSet={srcSet}
onClick={handleClick} onError={() => setImgproxy(false)} {...props}
2023-10-01 23:03:52 +00:00
/>
)
}
return <ImageOriginal src={originalUrl} onClick={handleClick} {...props} />
2023-10-01 23:03:52 +00:00
}
Image uploads (#576) * Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 20:53:33 +00:00
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(file => {
const img = new window.Image()
img.src = window.URL.createObjectURL(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(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(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)
}
})
}, [toaster, getSignedPOST])
return (
<>
<input
ref={ref}
type='file'
multiple={multiple}
className='d-none'
accept={UPLOAD_TYPES_ALLOW.join(', ')}
onChange={async (e) => {
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
}
}}
/>
<div className={className} onClick={() => ref.current?.click()} style={{ cursor: 'pointer' }}>
{children}
</div>
</>
)
})