profile
Some checks failed
Build and Release / build-and-release (push) Failing after 6m46s

This commit is contained in:
Bryan1029384756
2026-04-19 02:41:25 -05:00
parent 43cfbe65f3
commit 82f8a12e27
9 changed files with 735 additions and 29 deletions

View File

@@ -1270,7 +1270,7 @@ export function ChannelTextarea({
style={{ display: 'none' }}
onChange={handleFilesSelected}
/>
<Tooltip content="Upload a File" placement="top">
{isMobile ? (
<button
ref={plusButtonRef}
type="button"
@@ -1281,7 +1281,20 @@ export function ChannelTextarea({
>
<PlusCircle size={26} weight="fill" className={styles.textareaButtonIcon} />
</button>
</Tooltip>
) : (
<Tooltip content="Upload a File" placement="top">
<button
ref={plusButtonRef}
type="button"
className={styles.textareaButton}
aria-label="Upload a file or create a poll"
onClick={() => setShowPlusMenu((v) => !v)}
disabled={isUploading || !channelKey}
>
<PlusCircle size={26} weight="fill" className={styles.textareaButtonIcon} />
</button>
</Tooltip>
)}
</div>
<div className={styles.contentArea}>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -23,7 +23,7 @@
.banner {
position: relative;
flex-shrink: 0;
height: 160px;
aspect-ratio: 5 / 2;
width: 100%;
background-size: cover;
background-position: center;

View File

@@ -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;
}

View File

@@ -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<string | null>(null);
const [imageEl, setImageEl] = useState<HTMLImageElement | null>(null);
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<Modal.Root isOpen={isOpen} onClose={onClose} size="medium">
<Modal.Header title="Crop Banner" onClose={onClose} />
<Modal.Content>
<div className={styles.body}>
<p className={styles.description}>
Drag to reposition your banner and use the scroll wheel or
pinch to zoom. The recommended size is 1200×480 pixels (5:2).
</p>
<div
ref={editorRef}
className={styles.editor}
style={{ width: EDITOR_WIDTH, height: EDITOR_HEIGHT }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
>
{imageUrl && (
<img
src={imageUrl}
alt=""
className={styles.editorImage}
style={{
transform: imageTransform,
width: imageEl?.naturalWidth ?? 0,
height: imageEl?.naturalHeight ?? 0,
}}
draggable={false}
/>
)}
{/* 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. */}
<div className={styles.cropMask} aria-hidden />
</div>
<div className={styles.zoomRow}>
<label className={styles.zoomLabel} htmlFor="banner-zoom">
Zoom
</label>
<input
id="banner-zoom"
type="range"
className={styles.zoomSlider}
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
disabled={!imageEl}
/>
<button
type="button"
className={styles.iconButton}
onClick={handleFitFrame}
disabled={!imageEl}
aria-label="Fit to frame"
title="Fit to frame"
>
<FrameCorners size={20} weight="regular" />
</button>
<button
type="button"
className={styles.iconButton}
onClick={handleRotate}
disabled={!imageEl}
aria-label="Rotate 90°"
title="Rotate 90°"
>
<ArrowClockwise size={20} weight="regular" />
</button>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
type="button"
className={styles.resetButton}
onClick={handleReset}
disabled={!imageEl}
>
Reset
</button>
<div className={styles.actionsSpacer} />
<button
type="button"
className={styles.secondaryButton}
onClick={onClose}
disabled={isSaving}
>
Cancel
</button>
<button
type="button"
className={styles.tertiaryButton}
onClick={handleSkip}
disabled={isSaving}
title="Upload the original file without cropping"
>
Skip Cropping
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleSave}
disabled={!imageEl || isSaving}
>
{isSaving ? 'Saving…' : 'Save Banner'}
</button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}
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<Blob> {
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,
);
});
}

View File

@@ -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<Blob | null>(null);
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
const [removeBannerPending, setRemoveBannerPending] = useState(false);
const [bannerCropCandidate, setBannerCropCandidate] = useState<File | null>(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() {
>
<div
style={{
height: 72,
position: 'relative',
aspectRatio: '5 / 2',
background: currentBanner
? undefined
: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
@@ -468,23 +485,27 @@ export function AccountTab() {
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<div style={{ position: 'absolute', top: 36, left: 18 }}>
<button
type="button"
onClick={pickAvatar}
title="Change avatar"
style={{
width: 88,
height: 88,
padding: 0,
borderRadius: '50%',
border: '6px solid var(--background-secondary)',
background: 'var(--background-tertiary)',
overflow: 'hidden',
cursor: 'pointer',
}}
>
>
{/* 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). */}
<div style={{ position: 'absolute', bottom: -44, left: 18 }}>
<button
type="button"
onClick={pickAvatar}
title="Change avatar"
style={{
width: 88,
height: 88,
padding: 0,
borderRadius: '50%',
border: '6px solid var(--background-secondary)',
background: 'var(--background-tertiary)',
overflow: 'hidden',
cursor: 'pointer',
}}
>
{currentAvatar ? (
<img
src={currentAvatar}
@@ -508,6 +529,7 @@ export function AccountTab() {
</div>
)}
</button>
</div>
</div>
<div style={{ padding: '48px 18px 16px 18px' }}>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
@@ -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() {
<div
style={{
width: 160,
height: 48,
aspectRatio: '5 / 2',
borderRadius: 6,
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
border: '1px solid var(--background-modifier-accent)',
@@ -831,6 +853,14 @@ export function AccountTab() {
onCropped={handleCropped}
onSkipCrop={handleSkipCrop}
/>
<BannerCropModal
isOpen={!!bannerCropCandidate}
onClose={() => setBannerCropCandidate(null)}
file={bannerCropCandidate}
onCropped={handleBannerCropped}
onSkipCrop={handleBannerSkipCrop}
/>
</>
);
}