From c2ce97e8ced64c9771e52df230332bd9e182fed5 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 27 May 2026 12:47:45 -0700 Subject: [PATCH] Add profile image editor for account settings. Co-authored-by: Cursor --- .../profile-image-editor.tsx | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 apps/dashboard/src/components/dashboard-account-settings/profile-image-editor.tsx diff --git a/apps/dashboard/src/components/dashboard-account-settings/profile-image-editor.tsx b/apps/dashboard/src/components/dashboard-account-settings/profile-image-editor.tsx new file mode 100644 index 000000000..568d1d713 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/profile-image-editor.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { fileToBase64 } from '@stackframe/stack-shared/dist/utils/base64'; +import { runAsynchronouslyWithAlert } from '@stackframe/stack-shared/dist/utils/promises'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import imageCompression from 'browser-image-compression'; +import { UploadSimple, User } from '@phosphor-icons/react'; +import { useCallback, useState } from 'react'; +import Cropper, { Area } from 'react-easy-crop'; + +export async function checkImageUrl(url: string){ + try { + const res = await fetch(url, { method: 'HEAD' }); + const buff = await res.blob(); + return buff.type.startsWith('image/'); + } catch (e) { + return false; + } +} + +const createImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', (error) => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + +export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise { + const image = await createImage(imageSrc); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + return null; + } + + const safeCrop = { + x: Math.max(0, pixelCrop.x), + y: Math.max(0, pixelCrop.y), + width: Math.max(1, pixelCrop.width), + height: Math.max(1, pixelCrop.height), + }; + + canvas.width = safeCrop.width; + canvas.height = safeCrop.height; + + ctx.drawImage( + image, + safeCrop.x, + safeCrop.y, + safeCrop.width, + safeCrop.height, + 0, + 0, + safeCrop.width, + safeCrop.height + ); + + return canvas.toDataURL('image/jpeg'); +} + +export function ProfileImageEditor(props: { + user: { + profileImageUrl?: string | null; + displayName?: string | null; + primaryEmail?: string | null; + }, + onProfileImageUrlChange: (profileImageUrl: string | null) => void | Promise, +}) { + const [rawUrl, setRawUrl] = useState(null); + const [error, setError] = useState(null); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + function reset() { + setRawUrl(null); + setError(null); + setCrop({ x: 0, y: 0 }); + setZoom(1); + setCroppedAreaPixels(null); + } + + const onCropChange = useCallback((crop: { x: number, y: number }) => { + setCrop(crop); + }, []); + + const onCropComplete = useCallback((croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels); + }, []); + + const onZoomChange = useCallback((zoom: number) => { + setZoom(zoom); + }, []); + + function upload() { + const input = document.createElement('input'); + input.type = 'file'; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + runAsynchronouslyWithAlert(async () => { + const rawUrl = await fileToBase64(file); + if (await checkImageUrl(rawUrl)) { + setRawUrl(rawUrl); + setError(null); + } else { + setError('Invalid image'); + } + input.remove(); + }); + }; + input.click(); + } + + if (!rawUrl) { + const initials = (props.user.displayName || props.user.primaryEmail || '') + .slice(0, 2) + .toUpperCase(); + + return ( +
+
+ + + + {initials || } + + +
+
+ +
+
+
+ {error && {error}} +
+ ); + } + + return ( +
+
+ +
+ +
+ + onZoomChange(parseFloat(e.target.value))} + className="w-full h-1 bg-zinc-200 dark:bg-zinc-800 rounded-lg appearance-none cursor-pointer accent-black dark:accent-white" + /> +
+ +
+ + +
+
+ ); +}