Crop avatars with Imgproxy (#2074)
* cropPhoto mutation, crop avatars with Imgproxy * cropjob logging, conditional uploads url * comment typo * use public Imgproxy URL to re-upload cropped pic * fix avatar in dev --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
c33b62abb4
commit
b6f6cc821c
@ -85,6 +85,7 @@ IMGPROXY_READ_TIMEOUT=10
|
||||
IMGPROXY_WRITE_TIMEOUT=10
|
||||
IMGPROXY_DOWNLOAD_TIMEOUT=9
|
||||
IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1
|
||||
IMGPROXY_ALLOW_ORIGIN=http://localhost:3000
|
||||
# IMGPROXY_DEVELOPMENT_ERRORS_MODE=1
|
||||
# IMGPROXY_ENABLE_DEBUG_HEADERS=true
|
||||
|
||||
|
@ -11,6 +11,7 @@ import assertApiKeyNotPermitted from './apiKey'
|
||||
import { hashEmail } from '@/lib/crypto'
|
||||
import { isMuted } from '@/lib/user'
|
||||
import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error'
|
||||
import { processCrop } from '@/worker/imgproxy'
|
||||
|
||||
const contributors = new Set()
|
||||
|
||||
@ -727,6 +728,18 @@ export default {
|
||||
|
||||
return true
|
||||
},
|
||||
cropPhoto: async (parent, { photoId, cropData }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
}
|
||||
|
||||
const croppedUrl = await processCrop({ photoId: Number(photoId), cropData })
|
||||
if (!croppedUrl) {
|
||||
throw new GqlInputError('can\'t crop photo')
|
||||
}
|
||||
|
||||
return croppedUrl
|
||||
},
|
||||
setPhoto: async (parent, { photoId }, { me, models }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
|
@ -29,9 +29,20 @@ export default gql`
|
||||
users: [User!]!
|
||||
}
|
||||
|
||||
input CropData {
|
||||
x: Float!
|
||||
y: Float!
|
||||
width: Float!
|
||||
height: Float!
|
||||
originalWidth: Int!
|
||||
originalHeight: Int!
|
||||
scale: Float!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
setName(name: String!): String
|
||||
setSettings(settings: SettingsInput!): User
|
||||
cropPhoto(photoId: ID!, cropData: CropData): String!
|
||||
setPhoto(photoId: ID!): Int!
|
||||
upsertBio(text: String!): ItemPaidAction!
|
||||
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
||||
|
@ -6,12 +6,18 @@ import EditImage from '@/svgs/image-edit-fill.svg'
|
||||
import Moon from '@/svgs/moon-fill.svg'
|
||||
import { useShowModal } from './modal'
|
||||
import { FileUpload } from './file-upload'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
|
||||
export default function Avatar ({ onSuccess }) {
|
||||
const [cropPhoto] = useMutation(gql`
|
||||
mutation cropPhoto($photoId: ID!, $cropData: CropData) {
|
||||
cropPhoto(photoId: $photoId, cropData: $cropData)
|
||||
}
|
||||
`)
|
||||
const [uploading, setUploading] = useState()
|
||||
const showModal = useShowModal()
|
||||
|
||||
const Body = ({ onClose, file, upload }) => {
|
||||
const Body = ({ onClose, file, onSave }) => {
|
||||
const [scale, setScale] = useState(1)
|
||||
const ref = useRef()
|
||||
|
||||
@ -34,13 +40,21 @@ export default function Avatar ({ onSuccess }) {
|
||||
/>
|
||||
</BootstrapForm.Group>
|
||||
<Button
|
||||
onClick={() => {
|
||||
ref.current.getImageScaledToCanvas().toBlob(blob => {
|
||||
if (blob) {
|
||||
upload(blob)
|
||||
onClose()
|
||||
onClick={async () => {
|
||||
const rect = ref.current.getCroppingRect()
|
||||
const img = new window.Image()
|
||||
img.onload = async () => {
|
||||
const cropData = {
|
||||
...rect,
|
||||
originalWidth: img.width,
|
||||
originalHeight: img.height,
|
||||
scale
|
||||
}
|
||||
}, 'image/jpeg')
|
||||
// upload original to S3 along with crop data
|
||||
await onSave(cropData)
|
||||
}
|
||||
img.src = URL.createObjectURL(file)
|
||||
onClose()
|
||||
}}
|
||||
>save
|
||||
</Button>
|
||||
@ -48,6 +62,45 @@ export default function Avatar ({ onSuccess }) {
|
||||
)
|
||||
}
|
||||
|
||||
const startCrop = async (file, upload) => {
|
||||
return new Promise((resolve, reject) =>
|
||||
showModal(onClose => (
|
||||
<Body
|
||||
onClose={() => {
|
||||
onClose()
|
||||
resolve()
|
||||
}}
|
||||
file={file}
|
||||
onSave={async (cropData) => {
|
||||
setUploading(true)
|
||||
try {
|
||||
// upload original to S3
|
||||
const photoId = await upload(file)
|
||||
|
||||
// crop it
|
||||
const { data } = await cropPhoto({ variables: { photoId, cropData } })
|
||||
const res = await fetch(data.cropPhoto)
|
||||
const blob = await res.blob()
|
||||
|
||||
// create a file from the blob
|
||||
const croppedImage = new File([blob], 'avatar.jpg', { type: 'image/jpeg' })
|
||||
|
||||
// upload the imgproxy cropped image
|
||||
const croppedPhotoId = await upload(croppedImage)
|
||||
|
||||
onSuccess?.(croppedPhotoId)
|
||||
setUploading(false)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setUploading(false)
|
||||
reject(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FileUpload
|
||||
allow='image/*'
|
||||
@ -56,26 +109,7 @@ export default function Avatar ({ onSuccess }) {
|
||||
console.log(e)
|
||||
setUploading(false)
|
||||
}}
|
||||
onSelect={(file, upload) => {
|
||||
return new Promise((resolve, reject) =>
|
||||
showModal(onClose => (
|
||||
<Body
|
||||
onClose={() => {
|
||||
onClose()
|
||||
resolve()
|
||||
}}
|
||||
file={file}
|
||||
upload={async (blob) => {
|
||||
await upload(blob)
|
||||
resolve(blob)
|
||||
}}
|
||||
/>
|
||||
)))
|
||||
}}
|
||||
onSuccess={({ id }) => {
|
||||
onSuccess?.(id)
|
||||
setUploading(false)
|
||||
}}
|
||||
onSelect={startCrop}
|
||||
onUpload={() => {
|
||||
setUploading(true)
|
||||
}}
|
||||
|
@ -28,9 +28,11 @@ import { hexToBech32 } from '@/lib/nostr'
|
||||
import NostrIcon from '@/svgs/nostr.svg'
|
||||
import GithubIcon from '@/svgs/github-fill.svg'
|
||||
import TwitterIcon from '@/svgs/twitter-fill.svg'
|
||||
import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants'
|
||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||
import ItemPopover from './item-popover'
|
||||
|
||||
const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}`
|
||||
|
||||
export default function UserHeader ({ user }) {
|
||||
const router = useRouter()
|
||||
|
||||
|
@ -185,3 +185,30 @@ const sign = (target) => {
|
||||
hmac.update(target)
|
||||
return hmac.digest('base64url')
|
||||
}
|
||||
|
||||
export async function processCrop ({ photoId, cropData }) {
|
||||
const { x, y, width, height, originalWidth, originalHeight, scale } = cropData
|
||||
const cropWidth = Math.round(originalWidth * width)
|
||||
const cropHeight = Math.round(originalHeight * height)
|
||||
|
||||
const centerX = x + width / scale
|
||||
const centerY = y + height / scale
|
||||
|
||||
const size = 200 // 200px avatar size
|
||||
|
||||
const options = [
|
||||
`/crop:${cropWidth}:${cropHeight}`,
|
||||
`/gravity:fp:${centerX}:${centerY}`,
|
||||
`/rs:fill:${size}:${size}`
|
||||
].join('')
|
||||
|
||||
const uploadsUrl = process.env.MEDIA_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_URL
|
||||
const url = uploadsUrl + `/${photoId}`
|
||||
console.log('[imgproxy - cropjob] id:', photoId, '-- url:', url)
|
||||
|
||||
const pathname = '/'
|
||||
const path = createImgproxyPath({ url, pathname, options })
|
||||
const publicImgproxyUrl = process.env.NEXT_PUBLIC_IMGPROXY_URL
|
||||
|
||||
return new URL(path, publicImgproxyUrl).toString()
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user