Skip to content

Commit 440b24c

Browse files
siddhant1Siddhantclaude
authored
fix(ui): restore cover image preview in COVER_IMAGE_UPLOAD field (#27790)
* fix(ui): restore cover image preview in COVER_IMAGE_UPLOAD field The AddDomainForm refactor (#26951) switched to the core-components COVER_IMAGE_UPLOAD field, which only emitted a FileTrigger button and filename hint — losing the image preview, replace, and remove affordances that the legacy MUICoverImageUpload provided. Add a CoverImageUploadField in core-components that renders the preview (blob URL for File, raw url for backend strings), with hover Replace and Remove controls. Expose a renderPreview slot so consumers that need richer UX (e.g. drag-to-reposition) can override the default preview without further core changes. Authenticated URL resolution stays consumer-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ui): port UntitledUI FileUploadDropZone, add reposition + dimension validation Add a `FileUploadDropZone` component to core-components, ported from UntitledUI's open-source React components — gives us their official dropzone visuals (FeaturedIcon + "Click to upload" / "or drag and drop" + format hint, plus drag-and-drop, accept-type filtering, and size limit handling). Wire it into `CoverImageUploadField` as the empty state, and expand the field to restore the missing reposition flow and dimension/size validation that the legacy MUICoverImageUpload handled: - Drag-to-reposition (mouse, touch, keyboard) with Save / Cancel / Reset Position controls. Repositionable detection uses the image's scaled height vs. configurable previewHeight. - Size validation via the underlying FileUploadDropZone (maxSize in bytes, derived from maxSizeMB) with onSizeLimitExceed → consumer toast. - Dimension validation in the wrapper using natural width/height, with onValidationError callback for the consumer toast. - New props: maxSizeMB, maxDimensions, validationMessages, onValidationError, repositionable, coverImageLabels, previewHeight. All threaded through FieldPropsMap. `AddDomainForm` now passes the 5MB / 800x400 limits, i18n strings for all labels and errors, and shows validation toasts via notistack. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ui): cover image — pre-measure dims, reset position on replace, blob URL useEffect Three fixes to CoverImageUploadField; the FileUploadDropZone stays as the pristine UntitledUI port. - Pre-measure image dimensions inside acceptFile via a measureImage helper instead of the prior validate-only pass. The natural width and height are written into state alongside the form value, so the Reposition button reads the new image's dims on the same render the preview swaps in — no flicker through stale state. - Drop the carry-over of the previous file's percentage offset when accepting a replacement; new files start at default position. - Switch previewSrc from useMemo to useState + useEffect so the URL.createObjectURL side effect runs in commit phase, not render. Discarded concurrent renders no longer leak unrevoked blob URLs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ui): cover image — re-enable Reposition on first upload isImageRepositionable was a useMemo whose recompute keyed off the image's natural dimensions. On the very first upload, those dimensions land before the preview <div> mounts, so getScaledImageHeight reads a null containerRef and returns 0. The button then stayed disabled because nothing later triggered a recompute. Track the container width in state via a callback ref. The mount itself now schedules a re-render; getScaledImageHeight reads from state instead of poking offsetWidth mid-render. Replace already worked because the ref was attached by the time dimensions changed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ui): cover image — don't recreate blob URL on position-only changes The previewSrc useEffect depended on the whole value object. Saving a reposition mints a new value with the same File but a different position, so the effect was revoking and re-minting a blob URL for the same File on every save — wasted work plus a brief <img> flicker. Depend on stable file/url primitives extracted from value; position changes leave both untouched, so the effect no longer runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ui): apply organize-imports to AddDomainForm Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(ui): apply core-components eslint + prettier fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Siddhant <siddhant@MacBook-Pro-621.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b68e744 commit 440b24c

6 files changed

Lines changed: 992 additions & 69 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/*
2+
* Copyright 2025 Collate.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*
13+
* Portions of this file are derived from UntitledUI's open-source React
14+
* components, licensed under MIT.
15+
* Source: https://github.com/untitleduico/react/blob/main/components/application/file-upload/file-upload-base.tsx
16+
*/
17+
18+
import { UploadCloud02 } from '@untitledui/icons';
19+
import type { DragEvent, ChangeEvent } from 'react';
20+
import { useId, useRef, useState } from 'react';
21+
import { Button } from '@/components/base/buttons/button';
22+
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
23+
import { cx } from '@/utils/cx';
24+
25+
export const getReadableFileSize = (bytes: number): string => {
26+
if (bytes === 0) {
27+
return '0 KB';
28+
}
29+
30+
const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
31+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
32+
33+
return `${Math.floor(bytes / Math.pow(1024, i))} ${suffixes[i]}`;
34+
};
35+
36+
export interface FileUploadDropZoneProps {
37+
className?: string;
38+
hint?: string;
39+
isDisabled?: boolean;
40+
isInvalid?: boolean;
41+
accept?: string;
42+
allowsMultiple?: boolean;
43+
maxSize?: number;
44+
clickToUploadLabel?: string;
45+
orDragAndDropLabel?: string;
46+
'data-testid'?: string;
47+
onDropFiles?: (files: FileList) => void;
48+
onDropUnacceptedFiles?: (files: FileList) => void;
49+
onSizeLimitExceed?: (files: FileList) => void;
50+
}
51+
52+
const isFileTypeAccepted = (file: File, accept?: string): boolean => {
53+
if (!accept) {
54+
return true;
55+
}
56+
57+
const acceptedTypes = accept.split(',').map((type) => type.trim());
58+
59+
return acceptedTypes.some((acceptedType) => {
60+
if (acceptedType.startsWith('.')) {
61+
const extension = `.${file.name.split('.').pop()?.toLowerCase()}`;
62+
63+
return extension === acceptedType.toLowerCase();
64+
}
65+
66+
if (acceptedType.endsWith('/*')) {
67+
const typePrefix = acceptedType.split('/')[0];
68+
69+
return file.type.startsWith(`${typePrefix}/`);
70+
}
71+
72+
return file.type === acceptedType;
73+
});
74+
};
75+
76+
const filesToFileList = (files: File[]): FileList => {
77+
const dataTransfer = new DataTransfer();
78+
files.forEach((file) => dataTransfer.items.add(file));
79+
80+
return dataTransfer.files;
81+
};
82+
83+
export const FileUploadDropZone = ({
84+
className,
85+
hint,
86+
isDisabled,
87+
isInvalid: isInvalidProp,
88+
accept,
89+
allowsMultiple = true,
90+
maxSize,
91+
clickToUploadLabel = 'Click to upload',
92+
orDragAndDropLabel = 'or drag and drop',
93+
'data-testid': dataTestId,
94+
onDropFiles,
95+
onDropUnacceptedFiles,
96+
onSizeLimitExceed,
97+
}: FileUploadDropZoneProps) => {
98+
const id = useId();
99+
const inputRef = useRef<HTMLInputElement>(null);
100+
const [isInternalInvalid, setIsInternalInvalid] = useState(false);
101+
const [isDraggingOver, setIsDraggingOver] = useState(false);
102+
const isInvalid = isInvalidProp ?? isInternalInvalid;
103+
104+
const handleDragIn = (event: DragEvent<HTMLDivElement>) => {
105+
if (isDisabled) {
106+
return;
107+
}
108+
109+
event.preventDefault();
110+
event.stopPropagation();
111+
setIsDraggingOver(true);
112+
};
113+
114+
const handleDragOut = (event: DragEvent<HTMLDivElement>) => {
115+
if (isDisabled) {
116+
return;
117+
}
118+
119+
event.preventDefault();
120+
event.stopPropagation();
121+
setIsDraggingOver(false);
122+
};
123+
124+
const processFiles = (files: File[]): void => {
125+
setIsInternalInvalid(false);
126+
127+
const acceptedFiles: File[] = [];
128+
const unacceptedFiles: File[] = [];
129+
const oversizedFiles: File[] = [];
130+
131+
const filesToProcess = allowsMultiple ? files : files.slice(0, 1);
132+
133+
filesToProcess.forEach((file) => {
134+
if (maxSize && file.size > maxSize) {
135+
oversizedFiles.push(file);
136+
137+
return;
138+
}
139+
140+
if (isFileTypeAccepted(file, accept)) {
141+
acceptedFiles.push(file);
142+
} else {
143+
unacceptedFiles.push(file);
144+
}
145+
});
146+
147+
if (oversizedFiles.length > 0 && typeof onSizeLimitExceed === 'function') {
148+
setIsInternalInvalid(true);
149+
onSizeLimitExceed(filesToFileList(oversizedFiles));
150+
}
151+
152+
if (acceptedFiles.length > 0 && typeof onDropFiles === 'function') {
153+
onDropFiles(filesToFileList(acceptedFiles));
154+
}
155+
156+
if (
157+
unacceptedFiles.length > 0 &&
158+
typeof onDropUnacceptedFiles === 'function'
159+
) {
160+
setIsInternalInvalid(true);
161+
onDropUnacceptedFiles(filesToFileList(unacceptedFiles));
162+
}
163+
164+
if (inputRef.current) {
165+
inputRef.current.value = '';
166+
}
167+
};
168+
169+
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
170+
if (isDisabled) {
171+
return;
172+
}
173+
174+
handleDragOut(event);
175+
processFiles(Array.from(event.dataTransfer.files));
176+
};
177+
178+
const handleInputFileChange = (event: ChangeEvent<HTMLInputElement>) => {
179+
processFiles(Array.from(event.target.files || []));
180+
};
181+
182+
return (
183+
<div
184+
data-dropzone
185+
className={cx(
186+
'tw:relative tw:flex tw:flex-col tw:items-center tw:gap-3 tw:rounded-xl tw:bg-primary tw:px-6 tw:py-4 tw:text-tertiary tw:ring-1 tw:ring-secondary tw:transition tw:duration-100 tw:ease-linear tw:ring-inset',
187+
isDraggingOver && 'tw:ring-2 tw:ring-brand',
188+
isDisabled && 'tw:cursor-not-allowed tw:bg-secondary',
189+
className
190+
)}
191+
data-testid={dataTestId}
192+
onDragEnd={handleDragOut}
193+
onDragEnter={handleDragIn}
194+
onDragLeave={handleDragOut}
195+
onDragOver={handleDragIn}
196+
onDrop={handleDrop}>
197+
<FeaturedIcon
198+
className={cx(isDisabled && 'tw:opacity-50')}
199+
color="gray"
200+
icon={UploadCloud02}
201+
size="md"
202+
theme="modern"
203+
/>
204+
205+
<div className="tw:flex tw:flex-col tw:gap-1 tw:text-center">
206+
<div className="tw:flex tw:justify-center tw:gap-1 tw:text-center">
207+
<input
208+
accept={accept}
209+
className="tw:peer tw:sr-only"
210+
disabled={isDisabled}
211+
id={id}
212+
multiple={allowsMultiple}
213+
ref={inputRef}
214+
type="file"
215+
onChange={handleInputFileChange}
216+
/>
217+
<label className="tw:flex tw:cursor-pointer" htmlFor={id}>
218+
<Button
219+
color="link-color"
220+
isDisabled={isDisabled}
221+
size="md"
222+
onClick={() => inputRef.current?.click()}>
223+
{clickToUploadLabel}
224+
</Button>
225+
</label>
226+
<span className="tw:text-sm">{orDragAndDropLabel}</span>
227+
</div>
228+
{hint && (
229+
<p
230+
className={cx(
231+
'tw:text-xs tw:transition tw:duration-100 tw:ease-linear',
232+
isInvalid && 'tw:text-error-primary'
233+
)}>
234+
{hint}
235+
</p>
236+
)}
237+
</div>
238+
</div>
239+
);
240+
};
241+
242+
FileUploadDropZone.displayName = 'FileUploadDropZone';

0 commit comments

Comments
 (0)