-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): upload and crop a profile picture from the account page #4802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kathiekiwi
wants to merge
25
commits into
main
Choose a base branch
from
feat/profile-photo-upload
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
62060b8
feat(webapp): add a profile photo editor modal with circular crop and…
kathiekiwi 01619a9
feat(webapp): store profile photos in S3 and serve them presigned
kathiekiwi 8068b39
feat(webapp): change your profile picture from the account page
kathiekiwi 9245092
feat(webapp): delete the previous profile photo after a new one is st…
kathiekiwi 41dbfad
feat(webapp): verify profile photo bytes and return fetchable avatar …
kathiekiwi 569c46c
fix(webapp): size the avatar image to its container
kathiekiwi c25c7b0
fix(webapp): allow the avatar object store origin in the image policy
kathiekiwi cefdc34
fix(webapp): reject unsafe hosts when deriving an image policy origin
kathiekiwi b4cf0ae
feat(webapp): remove your profile photo from the account page
kathiekiwi 5aca646
feat(webapp): show, remove and drag-drop the profile picture in the p…
kathiekiwi 4516205
feat(webapp): show and remove the current profile picture from the ac…
kathiekiwi 8128436
fix(webapp): hide the remove button once a new profile picture is picked
kathiekiwi 89421bc
feat(webapp): serve avatar bytes from our own origin for re-cropping
kathiekiwi a651d06
feat(webapp): load the existing profile picture straight into the cro…
kathiekiwi fea2e6c
feat(webapp): show a tooltip on the account page profile picture
kathiekiwi 820a2a7
feat(webapp): show the saved profile picture statically in the photo …
kathiekiwi a614903
fix(webapp): move the remove button to the footer's right slot
kathiekiwi 3eac9a3
fix(webapp): drop refused uploads and block avatar writes while imper…
kathiekiwi 237e84d
fix(webapp): fall back to the picker when the saved photo fails to load
kathiekiwi 1a083d3
refactor(webapp): build the avatar routes with the dashboard route bu…
kathiekiwi 529bfe6
feat(webapp): give profile pictures their own object store
kathiekiwi 11afe1a
fix(webapp): only show uploaded photos in the profile picture editor
kathiekiwi 64e24f4
fix(webapp): hide profile picture uploads when no avatar store is con…
kathiekiwi 11af3dc
fix(webapp): reject protocol-relative avatar urls in the photo editor
kathiekiwi c4b7ae4
fix(webapp): sign avatar store requests as s3 and ignore blank config
kathiekiwi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
|
|
||
| You can now upload and crop your own profile picture from your account page, and remove it again whenever you like. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,273 @@ | ||
| import { MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon } from "@heroicons/react/20/solid"; | ||
| import { useEffect, useRef, useState } from "react"; | ||
| import Cropper, { type Area, type Point } from "react-easy-crop"; | ||
| import { cn } from "~/utils/cn"; | ||
| import { Button } from "./primitives/Buttons"; | ||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogFooter, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| } from "./primitives/Dialog"; | ||
| import { Paragraph } from "./primitives/Paragraph"; | ||
| import { Slider } from "./primitives/Slider"; | ||
|
|
||
| const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp"]; | ||
| const OUTPUT_SIZE = 512; | ||
| const MIN_ZOOM = 1; | ||
| const MAX_ZOOM = 3; | ||
| const ZOOM_STEP = 0.01; | ||
| const CENTER: Point = { x: 0, y: 0 }; | ||
|
|
||
| async function cropImageToBlob(imageSrc: string, area: Area): Promise<Blob> { | ||
| const image = await loadImage(imageSrc); | ||
| const canvas = document.createElement("canvas"); | ||
| canvas.width = OUTPUT_SIZE; | ||
| canvas.height = OUTPUT_SIZE; | ||
|
|
||
| const context = canvas.getContext("2d"); | ||
| if (!context) { | ||
| throw new Error("Could not create a canvas to crop the image"); | ||
| } | ||
|
|
||
| context.drawImage(image, area.x, area.y, area.width, area.height, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE); | ||
|
|
||
| return await new Promise((resolve, reject) => { | ||
| canvas.toBlob((blob) => { | ||
| if (blob) { | ||
| resolve(blob); | ||
| } else { | ||
| reject(new Error("Could not crop the image")); | ||
| } | ||
| }, "image/png"); | ||
| }); | ||
| } | ||
|
|
||
| function loadImage(src: string): Promise<HTMLImageElement> { | ||
| return new Promise((resolve, reject) => { | ||
| const image = new Image(); | ||
| image.addEventListener("load", () => resolve(image)); | ||
| image.addEventListener("error", () => reject(new Error("Could not load the image"))); | ||
| image.src = src; | ||
| }); | ||
| } | ||
|
|
||
| type ProfilePhotoEditorProps = { | ||
| open: boolean; | ||
| onOpenChange: (open: boolean) => void; | ||
| onSave: (blob: Blob) => void; | ||
| currentAvatarUrl?: string; | ||
| onRemove?: () => void; | ||
| isSaving?: boolean; | ||
| }; | ||
|
|
||
| export function ProfilePhotoEditor({ | ||
| open, | ||
| onOpenChange, | ||
| isSaving = false, | ||
| ...editorProps | ||
| }: ProfilePhotoEditorProps) { | ||
| return ( | ||
| <Dialog open={open} onOpenChange={onOpenChange}> | ||
| <DialogContent className="sm:max-w-md"> | ||
| <DialogHeader> | ||
| <DialogTitle>Profile picture</DialogTitle> | ||
| </DialogHeader> | ||
| {/* Radix unmounts the content when closed, so the crop state resets with it. */} | ||
| <Editor {...editorProps} isSaving={isSaving} /> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } | ||
|
|
||
| type EditorProps = Omit<ProfilePhotoEditorProps, "open" | "onOpenChange">; | ||
|
|
||
| function Editor({ onSave, currentAvatarUrl, onRemove, isSaving }: EditorProps) { | ||
| const fileInputRef = useRef<HTMLInputElement>(null); | ||
| const [imageSrc, setImageSrc] = useState<string>(); | ||
| const [crop, setCrop] = useState<Point>(CENTER); | ||
| const [zoom, setZoom] = useState(MIN_ZOOM); | ||
| const [croppedArea, setCroppedArea] = useState<Area>(); | ||
| const [error, setError] = useState<string>(); | ||
| const [isDraggingOver, setIsDraggingOver] = useState(false); | ||
| // Holding the url rather than a flag resets the fallback when it changes. | ||
| const [failedUrl, setFailedUrl] = useState<string>(); | ||
|
|
||
| const savedPhotoUrl = currentAvatarUrl === failedUrl ? undefined : currentAvatarUrl; | ||
|
|
||
| useEffect(() => { | ||
| if (!imageSrc) return; | ||
| return () => URL.revokeObjectURL(imageSrc); | ||
| }, [imageSrc]); | ||
|
|
||
| // A drop landing outside our own handlers would navigate the tab to the file | ||
| // and lose the crop. Editor only exists while the dialog is open. | ||
| useEffect(() => { | ||
| const suppress = (event: DragEvent) => event.preventDefault(); | ||
| window.addEventListener("dragover", suppress); | ||
| window.addEventListener("drop", suppress); | ||
| return () => { | ||
| window.removeEventListener("dragover", suppress); | ||
| window.removeEventListener("drop", suppress); | ||
| }; | ||
| }, []); | ||
|
|
||
| function selectFile(file: File | undefined) { | ||
| if (isSaving) return; | ||
| if (!file) return; | ||
|
|
||
| if (!ACCEPTED_TYPES.includes(file.type)) { | ||
| setError("Choose a PNG, JPEG or WebP image."); | ||
| return; | ||
| } | ||
|
|
||
| setCrop(CENTER); | ||
| setZoom(MIN_ZOOM); | ||
| setCroppedArea(undefined); | ||
| setError(undefined); | ||
| setImageSrc(URL.createObjectURL(file)); | ||
| } | ||
|
|
||
| async function save() { | ||
| if (!imageSrc || !croppedArea) return; | ||
|
|
||
| try { | ||
| onSave(await cropImageToBlob(imageSrc, croppedArea)); | ||
| } catch { | ||
| setError("Could not crop that image. Try another one."); | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <div | ||
| className="flex flex-col gap-4" | ||
| onDragOver={(event) => { | ||
| event.preventDefault(); | ||
| setIsDraggingOver(true); | ||
| }} | ||
| onDragLeave={(event) => { | ||
| // Moving between children fires dragleave too, so ignore inside targets. | ||
| if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; | ||
| setIsDraggingOver(false); | ||
| }} | ||
| onDrop={(event) => { | ||
| event.preventDefault(); | ||
| setIsDraggingOver(false); | ||
| selectFile(event.dataTransfer.files[0]); | ||
| }} | ||
| > | ||
| <div className="flex flex-col gap-4 pt-4"> | ||
| <input | ||
| ref={fileInputRef} | ||
| type="file" | ||
| accept="image/png,image/jpeg,image/webp" | ||
| className="hidden" | ||
| onChange={(event) => { | ||
| selectFile(event.target.files?.[0]); | ||
| // Or re-picking the same file after an error fires no change event. | ||
| event.target.value = ""; | ||
| }} | ||
| /> | ||
| {imageSrc ? ( | ||
| <> | ||
| <div | ||
| className={cn( | ||
| "relative h-64 w-full overflow-hidden rounded-md bg-charcoal-900 ring-1", | ||
| isDraggingOver ? "ring-primary" : "ring-transparent" | ||
| )} | ||
| > | ||
| <Cropper | ||
| image={imageSrc} | ||
| crop={crop} | ||
| zoom={zoom} | ||
| aspect={1} | ||
| cropShape="round" | ||
| showGrid={false} | ||
| minZoom={MIN_ZOOM} | ||
| maxZoom={MAX_ZOOM} | ||
| onCropChange={setCrop} | ||
| onZoomChange={setZoom} | ||
| onCropComplete={(_, areaPixels) => setCroppedArea(areaPixels)} | ||
| /> | ||
| </div> | ||
| <Slider | ||
| variant="settings" | ||
| aria-label="Zoom" | ||
| min={MIN_ZOOM} | ||
| max={MAX_ZOOM} | ||
| step={ZOOM_STEP} | ||
| value={[zoom]} | ||
| onValueChange={([value]) => setZoom(value)} | ||
| disabled={isSaving} | ||
| LeadingIcon={MagnifyingGlassMinusIcon} | ||
| TrailingIcon={MagnifyingGlassPlusIcon} | ||
| /> | ||
| </> | ||
| ) : savedPhotoUrl ? ( | ||
| <div | ||
| className={cn( | ||
| "flex h-64 w-full items-center justify-center rounded-md bg-charcoal-900 ring-1", | ||
| isDraggingOver ? "ring-primary" : "ring-transparent" | ||
| )} | ||
| > | ||
| {/* Fills the box like the cropper's circle, so switching doesn't jump. */} | ||
| <img | ||
| src={savedPhotoUrl} | ||
| alt="" | ||
| className="aspect-square h-full rounded-full object-cover" | ||
| draggable={false} | ||
| onError={() => setFailedUrl(savedPhotoUrl)} | ||
| /> | ||
| </div> | ||
| ) : ( | ||
| <button | ||
| type="button" | ||
| onClick={() => fileInputRef.current?.click()} | ||
| className={cn( | ||
| "flex h-64 w-full flex-col items-center justify-center gap-2 rounded-md border border-dashed text-text-dimmed transition hover:border-text-dimmed hover:text-text-bright", | ||
| isDraggingOver ? "border-primary" : "border-grid-bright" | ||
| )} | ||
| > | ||
| <Paragraph variant="small">Choose or drop an image</Paragraph> | ||
| <Paragraph variant="extra-small">PNG, JPEG or WebP</Paragraph> | ||
| </button> | ||
| )} | ||
| {error && ( | ||
| <Paragraph variant="small" className="text-error"> | ||
| {error} | ||
| </Paragraph> | ||
| )} | ||
| </div> | ||
| <DialogFooter> | ||
| <Button | ||
| variant="tertiary/medium" | ||
| onClick={() => fileInputRef.current?.click()} | ||
| disabled={isSaving} | ||
| > | ||
| {imageSrc || savedPhotoUrl ? "Choose another" : "Choose image"} | ||
| </Button> | ||
| {/* Nothing to save until a new file is cropped, so the saved photo offers | ||
| Remove in the same slot instead. Still offered when the preview failed | ||
| to load: there is a stored photo worth removing. */} | ||
| {imageSrc ? ( | ||
| <Button | ||
| variant="primary/medium" | ||
| onClick={save} | ||
| disabled={!croppedArea} | ||
| isLoading={isSaving} | ||
| > | ||
| Save | ||
| </Button> | ||
| ) : ( | ||
| onRemove && | ||
| currentAvatarUrl && ( | ||
| <Button variant="danger/medium" onClick={onRemove} disabled={isSaving}> | ||
| Remove | ||
| </Button> | ||
| ) | ||
| )} | ||
| </DialogFooter> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.