-
Notifications
You must be signed in to change notification settings - Fork 0
[REFACTOR] MediaUpload, ImageGallery 컴포넌트 사용성개선 #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,4 +1,4 @@ | ||||||
| import { ComponentProps, useId, useState } from 'react'; | ||||||
| import { ComponentProps, useId } from 'react'; | ||||||
|
|
||||||
| import { MediaPreview } from './MediaUploadPreview'; | ||||||
|
|
||||||
|
|
@@ -46,44 +46,43 @@ export type Props = { | |||||
| */ | ||||||
| multiple?: boolean; | ||||||
| /** | ||||||
| * initial preview urls for edit mode (e.g., existing uploaded image urls) | ||||||
| * Array of preview URLs to display existing media | ||||||
| */ | ||||||
| initialPreviewUrls?: string[]; | ||||||
| previewUrls?: string[]; | ||||||
| /** | ||||||
| * initial files for edit mode (if available). | ||||||
| * Array of preview File objects for new uploads | ||||||
| */ | ||||||
| initialFiles?: File[]; | ||||||
| previewFiles?: File[] | null; | ||||||
| /** | ||||||
| * Callback function called when files are selected, removed, or reset | ||||||
| */ | ||||||
| onFileChange?: (files: File[] | null, previewUrls: string[]) => void; | ||||||
| } & Omit<ComponentProps<'input'>, 'id'>; | ||||||
|
|
||||||
| const GB = 1024 * 1024 * 1024; | ||||||
|
|
||||||
| export function MediaUpload({ | ||||||
| topAffix, | ||||||
| onFileUpload, | ||||||
| id, | ||||||
| label = '파일을 업로드해주세요. (jpg, jpeg, png)', | ||||||
| description = '* 파일은 5GB까지 업로드 가능합니다.', | ||||||
| maxSize = 5, | ||||||
| acceptedFormats = ['image/*'], | ||||||
| multiple = false, | ||||||
| initialPreviewUrls, | ||||||
| initialFiles, | ||||||
| previewFiles = [], | ||||||
| previewUrls = [], | ||||||
| onFileChange, | ||||||
| ...props | ||||||
| }: Props) { | ||||||
| const generatedId = useId(); | ||||||
| const inputId = id || generatedId; | ||||||
|
|
||||||
| const [selectedFiles, setSelectedFiles] = useState<File[]>(initialFiles || []); | ||||||
| const [previewUrls, setPreviewUrls] = useState<string[]>(initialPreviewUrls || []); | ||||||
| const isSelected = selectedFiles.length > 0; | ||||||
| const isSelected = previewUrls.length > 0; | ||||||
|
|
||||||
| const handleReset = () => { | ||||||
| previewUrls.forEach((url) => { | ||||||
| if (url.startsWith('blob:')) URL.revokeObjectURL(url); | ||||||
| }); | ||||||
| setSelectedFiles([]); | ||||||
| setPreviewUrls([]); | ||||||
| onFileUpload?.(null); | ||||||
| onFileChange?.(null, []); | ||||||
| }; | ||||||
|
|
||||||
| const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|
|
@@ -97,27 +96,22 @@ export function MediaUpload({ | |||||
| } | ||||||
|
|
||||||
| const validatedFiles = multiple ? files : [files[0]]; | ||||||
| setSelectedFiles(validatedFiles); | ||||||
| const urls = validatedFiles.map((file) => URL.createObjectURL(file)); | ||||||
| setPreviewUrls(urls); | ||||||
| onFileUpload?.(validatedFiles); | ||||||
| onFileChange?.(validatedFiles, urls); | ||||||
| }; | ||||||
|
|
||||||
| const handleRemoveFile = (index: number) => { | ||||||
| const urlToRemove = previewUrls[index]; | ||||||
| if (urlToRemove?.startsWith('blob:')) { | ||||||
| URL.revokeObjectURL(urlToRemove); | ||||||
| } | ||||||
| const newFiles = selectedFiles.filter((_, i) => i !== index); | ||||||
| if (urlToRemove?.startsWith('blob:')) URL.revokeObjectURL(urlToRemove); | ||||||
|
|
||||||
| const newFiles = previewFiles?.filter((_, i) => i !== index) ?? null; | ||||||
| const newUrls = previewUrls.filter((_, i) => i !== index); | ||||||
|
|
||||||
| setSelectedFiles(newFiles); | ||||||
| setPreviewUrls(newUrls); | ||||||
| onFileUpload?.(newFiles.length > 0 ? newFiles : null); | ||||||
| onFileChange?.(newFiles, newUrls); | ||||||
| }; | ||||||
|
|
||||||
| return ( | ||||||
| <div className="max-h-[500px] w-full"> | ||||||
| <div className="max-h-[500px] w-full overflow-auto"> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial 세로 스크롤만 필요하다면
다음 diff를 적용하여 세로 스크롤만 활성화하세요: - <div className="max-h-[500px] w-full overflow-auto">
+ <div className="max-h-[500px] w-full overflow-y-auto">📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| <Flex justifyContent="between"> | ||||||
| <Body1 className="text-gray-400">{topAffix}</Body1> | ||||||
| <RefreshButton handleReset={handleReset} isSelected={isSelected} /> | ||||||
|
|
@@ -126,7 +120,7 @@ export function MediaUpload({ | |||||
| <UploadBox id={inputId} label={label} description={description} /> | ||||||
| ) : ( | ||||||
| <MediaPreview | ||||||
| files={selectedFiles} | ||||||
| files={previewFiles} | ||||||
| previewUrls={previewUrls} | ||||||
| onRemoveFile={handleRemoveFile} | ||||||
| multiple={multiple} | ||||||
|
|
@@ -178,7 +172,7 @@ function RefreshButton({ handleReset, isSelected }: RefreshButtonProp) { | |||||
| <Flex | ||||||
| as="button" | ||||||
| onClick={(e) => { | ||||||
| e.stopPropagation(); | ||||||
| e.preventDefault(); | ||||||
| if (isSelected) handleReset(); | ||||||
| }} | ||||||
| alignItems="center" | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,24 @@ | ||
| import { useState, useEffect } from 'react'; | ||
|
|
||
| import { Flex } from '../Flex'; | ||
| import { Icon } from '../Icon'; | ||
|
|
||
| type Props = { | ||
| files: File[]; | ||
| files: File[] | null; | ||
| previewUrls: string[]; | ||
| onRemoveFile: (index: number) => void; | ||
| multiple: boolean; | ||
| }; | ||
|
|
||
| export function MediaPreview({ files, previewUrls, onRemoveFile, multiple }: Props) { | ||
| if (!multiple) { | ||
| return <MediaPreviewItem file={files[0]} previewUrl={previewUrls[0]} />; | ||
| return <MediaPreviewItem file={files?.[0]} previewUrl={previewUrls[0]} />; | ||
| } | ||
| return ( | ||
| <div className="grid grid-cols-2 gap-2 md:grid-cols-3 md:gap-4"> | ||
| {files?.map((file, index) => ( | ||
| {previewUrls?.map((previewUrl, index) => ( | ||
| <div key={index} className="relative aspect-square"> | ||
| <MediaPreviewItem file={file} previewUrl={previewUrls[index]} /> | ||
| <MediaPreviewItem file={files?.[index]} previewUrl={previewUrl} /> | ||
| <button | ||
| type="button" | ||
| onClick={() => onRemoveFile(index)} | ||
|
|
@@ -31,17 +33,27 @@ export function MediaPreview({ files, previewUrls, onRemoveFile, multiple }: Pro | |
| } | ||
|
|
||
| type MediaPreviewItemProps = { | ||
| file: File; | ||
| file?: File; | ||
| previewUrl: string; | ||
| }; | ||
| function MediaPreviewItem({ file, previewUrl }: MediaPreviewItemProps) { | ||
| const [isVideo, setIsVideo] = useState<boolean>(false); | ||
|
|
||
| useEffect(() => { | ||
| if (file) return setIsVideo(file.type.startsWith('video/')); | ||
| getMimeType(previewUrl).then((type) => { | ||
| if (type) setIsVideo(type.startsWith('video/')); | ||
| else setIsVideo(false); | ||
| }); | ||
| }, [file, previewUrl]); | ||
|
|
||
|
Comment on lines
+42
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HEAD 요청에 중단/정합성 처리 없음 — 언마운트/빠른 전환 시 상태 경쟁 조건과 불필요 네트워크 발생
AbortController로 취소를 지원하고 res.ok를 확인하세요. - useEffect(() => {
- if (file) return setIsVideo(file.type.startsWith('video/'));
- getMimeType(previewUrl).then((type) => {
- if (type) setIsVideo(type.startsWith('video/'));
- else setIsVideo(false);
- });
- }, [file, previewUrl]);
+ useEffect(() => {
+ if (file) {
+ setIsVideo(file.type.startsWith('video/'));
+ return;
+ }
+ const ac = new AbortController();
+ let cancelled = false;
+ getMimeType(previewUrl, ac.signal).then((type) => {
+ if (!cancelled) setIsVideo(Boolean(type?.startsWith('video/')));
+ });
+ return () => {
+ cancelled = true;
+ ac.abort();
+ };
+ }, [file, previewUrl]);
@@
-async function getMimeType(url: string): Promise<string | null> {
+async function getMimeType(url: string, signal?: AbortSignal): Promise<string | null> {
try {
- const res = await fetch(url, { method: 'HEAD' });
- return res.headers.get('Content-Type');
+ const res = await fetch(url, { method: 'HEAD', signal });
+ if (!res.ok) return null;
+ return res.headers.get('Content-Type');
} catch {
return null;
}
}추가로, 외부 CDN CORS 정책으로 HEAD가 막힐 수 있어 캐시(Map<url, mime>)를 두어 재시도/중복요청을 줄이는 것도 권장합니다. Also applies to: 73-80 |
||
| return ( | ||
| <Flex | ||
| justifyContent="center" | ||
| alignItems="center" | ||
| className="relative h-full w-full rounded-xl border border-gray-200 bg-gray-50" | ||
| > | ||
| {file.type.startsWith('video/') ? ( | ||
| {isVideo ? ( | ||
| <video | ||
| src={previewUrl} | ||
| controls | ||
|
|
@@ -50,10 +62,19 @@ function MediaPreviewItem({ file, previewUrl }: MediaPreviewItemProps) { | |
| ) : ( | ||
| <img | ||
| src={previewUrl} | ||
| alt={`미리보기 ${file.name}`} | ||
| alt={file ? `미리보기 ${file.name}` : '미리보기 이미지'} | ||
| className="h-full max-h-[500px] w-full max-w-[500px] object-contain" | ||
| /> | ||
| )} | ||
| </Flex> | ||
| ); | ||
| } | ||
|
|
||
| async function getMimeType(url: string): Promise<string | null> { | ||
| try { | ||
| const res = await fetch(url, { method: 'HEAD' }); | ||
| return res.headers.get('Content-Type'); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ESLint 경고로 파이프라인 실패 — 구조 분해 할당으로 수정하고 불필요한 prop 전파 차단
아래처럼 구조 분해 + 나머지 전달로 해결하세요.
📝 Committable suggestion
🧰 Tools
🪛 GitHub Actions: Check Pull Request
[warning] 16-16: Step 'npm run lint' reported 1 ESLint warning: 'Must use destructuring assignment' in MediaUpload.stories.tsx:16:60. ESLint found too many warnings (maximum: 0). Process completed with exit code 1.
🤖 Prompt for AI Agents