From 82f8a12e27fe55015e809c9a03d9c1c7d8320b04 Mon Sep 17 00:00:00 2001
From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com>
Date: Sun, 19 Apr 2026 02:41:25 -0500
Subject: [PATCH] profile
---
.claude/settings.local.json | 8 +-
.../components/channel/ChannelTextarea.tsx | 17 +-
.../layout/UserAreaProfilePopout.module.css | 2 +-
.../member/MemberProfileModal.module.css | 2 +-
.../member/MemberProfilePopout.module.css | 2 +-
.../MobileMemberProfileSheet.module.css | 2 +-
.../settings/BannerCropModal.module.css | 245 +++++++++++
.../components/settings/BannerCropModal.tsx | 412 ++++++++++++++++++
.../components/settings/UserSettingsModal.tsx | 74 +++-
9 files changed, 735 insertions(+), 29 deletions(-)
create mode 100644 packages/shared/src/components/settings/BannerCropModal.module.css
create mode 100644 packages/shared/src/components/settings/BannerCropModal.tsx
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index dc419f4..326ae65 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -60,7 +60,13 @@
"Bash(grep -E '\"electron\":\\\\s*\"' package.json apps/electron/package.json)",
"Bash(grep -E '\"electron\":\\\\s*\"' package.json)",
"Bash(node --check apps/electron/main.cjs)",
- "Bash(node --check apps/electron/preload.cjs)"
+ "Bash(node --check apps/electron/preload.cjs)",
+ "Bash(awk 'NR>=508 && NR<=535 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
+ "Bash(awk 'NR>=475 && NR<=535 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
+ "Bash(awk 'NR>=535 && NR<=560 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
+ "Bash(awk 'NR>=460 && NR<=480 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)",
+ "Bash(python3 -)",
+ "Bash(awk 'NR>=525 && NR<=540 {print NR\": \"$0}' packages/shared/src/components/settings/UserSettingsModal.tsx)"
]
}
}
diff --git a/packages/shared/src/components/channel/ChannelTextarea.tsx b/packages/shared/src/components/channel/ChannelTextarea.tsx
index 4c3d919..744a86d 100644
--- a/packages/shared/src/components/channel/ChannelTextarea.tsx
+++ b/packages/shared/src/components/channel/ChannelTextarea.tsx
@@ -1270,7 +1270,7 @@ export function ChannelTextarea({
style={{ display: 'none' }}
onChange={handleFilesSelected}
/>
-
+ {isMobile ? (
-
+ ) : (
+
+ setShowPlusMenu((v) => !v)}
+ disabled={isUploading || !channelKey}
+ >
+
+
+
+ )}
diff --git a/packages/shared/src/components/layout/UserAreaProfilePopout.module.css b/packages/shared/src/components/layout/UserAreaProfilePopout.module.css
index f012039..770ef35 100644
--- a/packages/shared/src/components/layout/UserAreaProfilePopout.module.css
+++ b/packages/shared/src/components/layout/UserAreaProfilePopout.module.css
@@ -44,7 +44,7 @@
.banner {
width: 100%;
- height: 90px;
+ aspect-ratio: 5 / 2;
background-color: var(--brand-primary, #5865f2);
background-size: cover;
background-position: center;
diff --git a/packages/shared/src/components/member/MemberProfileModal.module.css b/packages/shared/src/components/member/MemberProfileModal.module.css
index a153301..7713368 100644
--- a/packages/shared/src/components/member/MemberProfileModal.module.css
+++ b/packages/shared/src/components/member/MemberProfileModal.module.css
@@ -53,7 +53,7 @@
.banner {
flex: 0 0 auto;
- height: 210px;
+ aspect-ratio: 5 / 2;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
diff --git a/packages/shared/src/components/member/MemberProfilePopout.module.css b/packages/shared/src/components/member/MemberProfilePopout.module.css
index b5f81dc..aadf5ba 100644
--- a/packages/shared/src/components/member/MemberProfilePopout.module.css
+++ b/packages/shared/src/components/member/MemberProfilePopout.module.css
@@ -11,7 +11,7 @@
.banner {
flex: 0 0 auto;
- height: 110px;
+ aspect-ratio: 5 / 2;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
diff --git a/packages/shared/src/components/member/MobileMemberProfileSheet.module.css b/packages/shared/src/components/member/MobileMemberProfileSheet.module.css
index fabaa78..5ef7356 100644
--- a/packages/shared/src/components/member/MobileMemberProfileSheet.module.css
+++ b/packages/shared/src/components/member/MobileMemberProfileSheet.module.css
@@ -23,7 +23,7 @@
.banner {
position: relative;
flex-shrink: 0;
- height: 160px;
+ aspect-ratio: 5 / 2;
width: 100%;
background-size: cover;
background-position: center;
diff --git a/packages/shared/src/components/settings/BannerCropModal.module.css b/packages/shared/src/components/settings/BannerCropModal.module.css
new file mode 100644
index 0000000..cfd942d
--- /dev/null
+++ b/packages/shared/src/components/settings/BannerCropModal.module.css
@@ -0,0 +1,245 @@
+/* ── Crop Banner modal body ──────────────────────────────────
+ Mirrors AvatarCropModal.module.css; the only meaningful
+ difference is `.cropMask` which is a rounded rectangle here
+ (matching the 5:2 banner frame) instead of a circle. */
+
+.body {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 0;
+}
+
+.description {
+ margin: 0;
+ font-size: 0.875rem;
+ line-height: 1.4;
+ color: var(--text-secondary);
+}
+
+.editor {
+ position: relative;
+ align-self: center;
+ background-color: #000;
+ border-radius: 8px;
+ overflow: hidden;
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.editor:active {
+ cursor: grabbing;
+}
+
+.editorImage {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform-origin: center center;
+ pointer-events: none;
+ max-width: none;
+ max-height: none;
+ image-rendering: auto;
+}
+
+.cropMask {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 100%;
+ height: 100%;
+ transform: translate(-50%, -50%);
+ border-radius: 6px;
+ border: 2px solid rgba(255, 255, 255, 0.85);
+ box-sizing: border-box;
+ pointer-events: none;
+ box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
+}
+
+.zoomRow {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.zoomLabel {
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-primary-muted, #a0a3a8);
+ flex-shrink: 0;
+}
+
+.zoomSlider {
+ flex: 1;
+ height: 4px;
+ -webkit-appearance: none;
+ appearance: none;
+ background-color: var(--background-modifier-accent);
+ border-radius: 999px;
+ cursor: pointer;
+ outline: none;
+}
+
+.zoomSlider:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.zoomSlider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--brand-primary);
+ cursor: pointer;
+ border: none;
+}
+
+.zoomSlider::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--brand-primary);
+ cursor: pointer;
+ border: none;
+}
+
+.iconButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: 6px;
+ color: var(--text-primary-muted, #a0a3a8);
+ cursor: pointer;
+ transition: background-color 0.12s, color 0.12s;
+}
+
+.iconButton:hover:not(:disabled) {
+ background-color: var(--background-modifier-hover);
+ color: var(--text-primary);
+}
+
+.iconButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.error {
+ padding: 10px 14px;
+ background-color: hsl(0, calc(60% * var(--saturation-factor, 1)), 22%);
+ color: hsl(0, calc(80% * var(--saturation-factor, 1)), 85%);
+ border-radius: 6px;
+ font-size: 0.8125rem;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+.actionsSpacer {
+ flex: 1;
+}
+
+.resetButton {
+ height: 40px;
+ padding: 0 18px;
+ background: var(--background-secondary-alt);
+ border: none;
+ border-radius: 6px;
+ color: var(--text-primary);
+ font: inherit;
+ font-size: 0.875rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background-color 0.12s;
+}
+
+.resetButton:hover:not(:disabled) {
+ background-color: var(--background-modifier-hover);
+}
+
+.resetButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.secondaryButton {
+ height: 40px;
+ padding: 0 18px;
+ background: transparent;
+ border: none;
+ border-radius: 6px;
+ color: var(--text-primary);
+ font: inherit;
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background-color 0.12s;
+}
+
+.secondaryButton:hover:not(:disabled) {
+ background-color: var(--background-modifier-hover);
+}
+
+.tertiaryButton {
+ height: 40px;
+ padding: 0 18px;
+ background: var(--background-secondary-alt);
+ border: none;
+ border-radius: 6px;
+ color: var(--text-primary);
+ font: inherit;
+ font-size: 0.875rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background-color 0.12s;
+}
+
+.tertiaryButton:hover:not(:disabled) {
+ background-color: var(--background-modifier-hover);
+}
+
+.primaryButton {
+ height: 40px;
+ padding: 0 20px;
+ background-color: var(--brand-primary);
+ border: none;
+ border-radius: 6px;
+ color: var(--text-on-brand-primary, #fff);
+ font: inherit;
+ font-size: 0.875rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: filter 0.12s;
+}
+
+.primaryButton:hover:not(:disabled) {
+ filter: brightness(1.08);
+}
+
+.primaryButton:active:not(:disabled) {
+ filter: brightness(0.92);
+}
+
+.primaryButton:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.secondaryButton:disabled,
+.tertiaryButton:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
diff --git a/packages/shared/src/components/settings/BannerCropModal.tsx b/packages/shared/src/components/settings/BannerCropModal.tsx
new file mode 100644
index 0000000..8724340
--- /dev/null
+++ b/packages/shared/src/components/settings/BannerCropModal.tsx
@@ -0,0 +1,412 @@
+/**
+ * BannerCropModal — opened after the user picks a banner file in
+ * the profile settings. Same drag/zoom/canvas-export pattern as
+ * AvatarCropModal, but with a 5:2 rectangular crop frame instead
+ * of a circle so every render surface (settings preview, member
+ * popout, member modal, mobile sheet, user-area popout) lines up
+ * on the same crop edges.
+ *
+ * Output is a 1200×480 PNG (matches Discord's profile banner
+ * ratio). Display surfaces use `aspect-ratio: 5 / 2` so the
+ * uploaded image lines up edge-to-edge regardless of width.
+ */
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type PointerEvent as ReactPointerEvent,
+ type WheelEvent as ReactWheelEvent,
+} from 'react';
+import { Modal } from '@discord-clone/ui';
+import {
+ ArrowClockwise,
+ FrameCorners,
+} from '@phosphor-icons/react';
+import styles from './BannerCropModal.module.css';
+
+// 5:2 rectangle. Editor footprint fits the medium modal width;
+// output is high-res so the same crop stays sharp on a 600-wide
+// MemberProfileModal banner and on retina displays.
+const EDITOR_WIDTH = 480;
+const EDITOR_HEIGHT = 192;
+const OUTPUT_WIDTH = 1200;
+const OUTPUT_HEIGHT = 480;
+const ZOOM_MIN = 1;
+const ZOOM_MAX = 4;
+const ZOOM_STEP = 0.05;
+
+interface BannerCropModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ file: File | null;
+ onCropped: (blob: Blob) => void;
+ onSkipCrop: () => void;
+}
+
+interface Offset {
+ x: number;
+ y: number;
+}
+
+export function BannerCropModal({
+ isOpen,
+ onClose,
+ file,
+ onCropped,
+ onSkipCrop,
+}: BannerCropModalProps) {
+ const [imageUrl, setImageUrl] = useState
(null);
+ const [imageEl, setImageEl] = useState(null);
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
+ const [zoom, setZoom] = useState(1);
+ const [rotation, setRotation] = useState(0);
+ const [isSaving, setIsSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const editorRef = useRef(null);
+ const dragRef = useRef<{
+ pointerId: number;
+ startX: number;
+ startY: number;
+ startOffset: Offset;
+ } | null>(null);
+
+ useEffect(() => {
+ if (!isOpen || !file) {
+ setImageUrl(null);
+ setImageEl(null);
+ return;
+ }
+ const url = URL.createObjectURL(file);
+ setImageUrl(url);
+ const img = new Image();
+ img.onload = () => setImageEl(img);
+ img.onerror = () => setError('Could not read that image.');
+ img.src = url;
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ }, [isOpen, file]);
+
+ useEffect(() => {
+ if (!imageEl) return;
+ setOffset({ x: 0, y: 0 });
+ setZoom(1);
+ setRotation(0);
+ setError(null);
+ }, [imageEl]);
+
+ // Cover scale for a non-square viewport: take the larger of the
+ // two axis ratios so the source always fully covers the editor.
+ const coverScale = useMemo(() => {
+ if (!imageEl) return 1;
+ return Math.max(
+ EDITOR_WIDTH / imageEl.naturalWidth,
+ EDITOR_HEIGHT / imageEl.naturalHeight,
+ );
+ }, [imageEl]);
+
+ const effectiveScale = coverScale * zoom;
+
+ const displayWidth = imageEl
+ ? imageEl.naturalWidth * effectiveScale
+ : EDITOR_WIDTH;
+ const displayHeight = imageEl
+ ? imageEl.naturalHeight * effectiveScale
+ : EDITOR_HEIGHT;
+
+ const clampOffset = useCallback(
+ (raw: Offset): Offset => {
+ const maxX = Math.max(0, (displayWidth - EDITOR_WIDTH) / 2);
+ const maxY = Math.max(0, (displayHeight - EDITOR_HEIGHT) / 2);
+ return {
+ x: Math.max(-maxX, Math.min(maxX, raw.x)),
+ y: Math.max(-maxY, Math.min(maxY, raw.y)),
+ };
+ },
+ [displayWidth, displayHeight],
+ );
+
+ useEffect(() => {
+ setOffset((prev) => clampOffset(prev));
+ }, [clampOffset]);
+
+ const handlePointerDown = (e: ReactPointerEvent) => {
+ if (!imageEl) return;
+ (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
+ dragRef.current = {
+ pointerId: e.pointerId,
+ startX: e.clientX,
+ startY: e.clientY,
+ startOffset: offset,
+ };
+ };
+
+ const handlePointerMove = (e: ReactPointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== e.pointerId) return;
+ const dx = e.clientX - drag.startX;
+ const dy = e.clientY - drag.startY;
+ setOffset(
+ clampOffset({
+ x: drag.startOffset.x + dx,
+ y: drag.startOffset.y + dy,
+ }),
+ );
+ };
+
+ const handlePointerUp = (e: ReactPointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== e.pointerId) return;
+ try {
+ (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
+ } catch {
+ // Already released — non-issue.
+ }
+ dragRef.current = null;
+ };
+
+ const handleWheel = (e: ReactWheelEvent) => {
+ if (!imageEl) return;
+ const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
+ setZoom((prev) => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev + delta)));
+ };
+
+ const handleReset = () => {
+ setOffset({ x: 0, y: 0 });
+ setZoom(1);
+ setRotation(0);
+ };
+
+ const handleRotate = () => {
+ setRotation((prev) => (prev + 90) % 360);
+ };
+
+ const handleFitFrame = () => {
+ setZoom(1);
+ setOffset({ x: 0, y: 0 });
+ };
+
+ const handleSkip = () => {
+ onSkipCrop();
+ onClose();
+ };
+
+ const handleSave = async () => {
+ if (!imageEl) return;
+ setIsSaving(true);
+ setError(null);
+ try {
+ const blob = await renderCrop({
+ image: imageEl,
+ editorWidth: EDITOR_WIDTH,
+ editorHeight: EDITOR_HEIGHT,
+ outputWidth: OUTPUT_WIDTH,
+ outputHeight: OUTPUT_HEIGHT,
+ offset,
+ scale: effectiveScale,
+ rotation,
+ });
+ onCropped(blob);
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to crop image.');
+ setIsSaving(false);
+ }
+ };
+
+ const imageTransform = imageEl
+ ? `translate(-50%, -50%) translate(${offset.x}px, ${offset.y}px) rotate(${rotation}deg) scale(${effectiveScale})`
+ : undefined;
+
+ return (
+
+
+
+
+
+ Drag to reposition your banner and use the scroll wheel or
+ pinch to zoom. The recommended size is 1200×480 pixels (5:2).
+
+
+
+ {imageUrl && (
+
+ )}
+ {/* Rounded-rectangle crop mask. Same outer-shadow
+ technique as the avatar crop, but the mask
+ matches the 5:2 editor frame so the crop
+ region is identical to the rendered output. */}
+
+
+
+
+
+ Zoom
+
+
setZoom(Number(e.target.value))}
+ disabled={!imageEl}
+ />
+
+
+
+
+
+
+
+
+ {error &&
{error}
}
+
+
+
+ Reset
+
+
+
+ Cancel
+
+
+ Skip Cropping
+
+
+ {isSaving ? 'Saving…' : 'Save Banner'}
+
+
+
+
+
+ );
+}
+
+interface RenderCropArgs {
+ image: HTMLImageElement;
+ editorWidth: number;
+ editorHeight: number;
+ outputWidth: number;
+ outputHeight: number;
+ offset: Offset;
+ scale: number;
+ rotation: number;
+}
+
+/**
+ * Bakes the editor transform into a PNG Blob sized
+ * `outputWidth × outputHeight`. Editor and output must share the
+ * same aspect ratio so the uniform `ratio` scalar can map every
+ * transform from editor space into output space.
+ */
+function renderCrop(args: RenderCropArgs): Promise {
+ return new Promise((resolve, reject) => {
+ const {
+ image,
+ editorWidth,
+ outputWidth,
+ outputHeight,
+ offset,
+ scale,
+ rotation,
+ } = args;
+ const canvas = document.createElement('canvas');
+ canvas.width = outputWidth;
+ canvas.height = outputHeight;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ reject(new Error('Could not get 2D canvas context.'));
+ return;
+ }
+
+ // Editor and output share an aspect ratio, so a single
+ // scalar maps transforms between the two spaces.
+ const ratio = outputWidth / editorWidth;
+
+ ctx.save();
+ ctx.translate(outputWidth / 2, outputHeight / 2);
+ ctx.translate(offset.x * ratio, offset.y * ratio);
+ ctx.rotate((rotation * Math.PI) / 180);
+ const drawScale = scale * ratio;
+ ctx.drawImage(
+ image,
+ (-image.naturalWidth * drawScale) / 2,
+ (-image.naturalHeight * drawScale) / 2,
+ image.naturalWidth * drawScale,
+ image.naturalHeight * drawScale,
+ );
+ ctx.restore();
+
+ canvas.toBlob(
+ (blob) => {
+ if (!blob) {
+ reject(new Error('Canvas export failed.'));
+ return;
+ }
+ resolve(blob);
+ },
+ 'image/png',
+ 0.95,
+ );
+ });
+}
diff --git a/packages/shared/src/components/settings/UserSettingsModal.tsx b/packages/shared/src/components/settings/UserSettingsModal.tsx
index b89c65b..38f8bed 100644
--- a/packages/shared/src/components/settings/UserSettingsModal.tsx
+++ b/packages/shared/src/components/settings/UserSettingsModal.tsx
@@ -18,6 +18,7 @@ import { useIsMobile } from '../../hooks/useIsMobile';
import { useLogout } from '../../hooks/useLogout';
import { usePlatform } from '../../platform';
import { AvatarCropModal } from './AvatarCropModal';
+import { BannerCropModal } from './BannerCropModal';
import { KeybindsTab } from './KeybindsTab';
import { MobileUserSettings } from './MobileUserSettings';
import { LogoutConfirmModal } from '../modals/LogoutConfirmModal';
@@ -226,6 +227,7 @@ export function AccountTab() {
const [pendingBannerBlob, setPendingBannerBlob] = useState(null);
const [bannerPreview, setBannerPreview] = useState(null);
const [removeBannerPending, setRemoveBannerPending] = useState(false);
+ const [bannerCropCandidate, setBannerCropCandidate] = useState(null);
useEffect(() => {
if (me) {
@@ -302,10 +304,24 @@ export function AccountTab() {
setStatus('Banner must be under 10 MB.');
return;
}
- setPendingBannerBlob(file);
+ setBannerCropCandidate(file);
+ };
+
+ const handleBannerCropped = (blob: Blob) => {
+ setPendingBannerBlob(blob);
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
- setBannerPreview(URL.createObjectURL(file));
+ setBannerPreview(URL.createObjectURL(blob));
setRemoveBannerPending(false);
+ setBannerCropCandidate(null);
+ };
+
+ const handleBannerSkipCrop = () => {
+ if (!bannerCropCandidate) return;
+ setPendingBannerBlob(bannerCropCandidate);
+ if (bannerPreview) URL.revokeObjectURL(bannerPreview);
+ setBannerPreview(URL.createObjectURL(bannerCropCandidate));
+ setRemoveBannerPending(false);
+ setBannerCropCandidate(null);
};
const handleRemoveBanner = () => {
@@ -460,7 +476,8 @@ export function AccountTab() {
>
-
-
+ >
+ {/* Avatar straddles the banner's bottom edge so the
+ overlap stays consistent regardless of banner
+ height (which now scales with the wrapper width
+ via aspect-ratio: 5/2). */}
+
+
{currentAvatar ? (
)}
+
@@ -634,7 +656,7 @@ export function AccountTab() {
alt="Banner preview"
style={{
width: 160,
- height: 48,
+ aspectRatio: '5 / 2',
borderRadius: 6,
objectFit: 'cover',
border: '1px solid var(--background-modifier-accent)',
@@ -644,7 +666,7 @@ export function AccountTab() {
+
+
setBannerCropCandidate(null)}
+ file={bannerCropCandidate}
+ onCropped={handleBannerCropped}
+ onSkipCrop={handleBannerSkipCrop}
+ />
>
);
}