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 ? ( - + ) : ( + + + + )}
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. */} +
+
+ +
+ + setZoom(Number(e.target.value))} + disabled={!imageEl} + /> + + +
+ + {error &&
{error}
} + +
+ +
+ + + +
+
+ + + ); +} + +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() { >
-
- +
@@ -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} + /> ); }