Skip to content
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

Refactor/upload image #237

Open
wants to merge 8 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules
npm-debug.log
.DS_Store
Dockerfile
.dockerignore
.git
.gitignore
*.log
.env
.env.production
1,875 changes: 948 additions & 927 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
]
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
Expand Down
18 changes: 15 additions & 3 deletions public/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
* - Please do NOT serve this file on production.
*/

const PACKAGE_VERSION = '2.6.6'
const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074'
const PACKAGE_VERSION = '2.6.8'
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

Expand Down Expand Up @@ -199,7 +199,19 @@ async function getResponse(event, client, requestId) {
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
headers.delete('accept', 'msw/passthrough')
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)

if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}

return fetch(requestClone, { headers })
}
Expand Down
22 changes: 12 additions & 10 deletions src/app/layout/ui/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,18 @@ export const Navigation = ({ active }: { active: string }) => {
) : 0

return (
<nav className='flex items-center h-full -mx-[22.5px]'>
{Object.entries(NAV_ICONS).map(([name, value]) => (
<NavigationItem
key={name}
name={name}
active={active === name}
path={value.path}
unreadNotificationsCount={unreadNotificationsCount}
/>
))}
<nav className="flex items-center h-full -mx-[22.5px]" aria-label="main navigation">
<ul className='flex w-full' role='menu'>
{Object.entries(NAV_ICONS).map(([name, value]) => (
<NavigationItem
key={name}
name={name}
active={active === name}
path={value.path}
unreadNotificationsCount={unreadNotificationsCount}
/>
))}
</ul>
</nav>
);
};
5 changes: 3 additions & 2 deletions src/app/main.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import './index.css';

import { storeLogin } from '@/features/auth/model/authSlice';
import ReactDOM from 'react-dom/client';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { Toaster } from 'sonner';
import App from './App';
Expand Down Expand Up @@ -35,7 +35,8 @@ async function setupMocks(): Promise<void> {

await setupMocks()

ReactDOM.createRoot(document.getElementById('root')!).render(
const root = createRoot(document.getElementById('root')!)
root.render(
<ReactQueryProvider>
<Provider store={store}>
<App />
Expand Down
1 change: 1 addition & 0 deletions src/features/register/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { getAuctionUploadURLs } from './getAuctionUploadURLs';
export { postAuction } from './postAuction';
export { uploadImagesToS3 } from './uploadImagesToS3';
1 change: 1 addition & 0 deletions src/features/register/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './config';
export * from './lib';
export * from './model';
export * from './ui';
export * from './utils';
47 changes: 47 additions & 0 deletions src/features/register/lib/imageCompressionWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/// <reference lib="webworker" />

import { toast } from 'sonner';

interface WorkerMessage {
fileData: ArrayBuffer;
fileName: string;
}

self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
const { fileData, fileName } = e.data;

try {
const blob = new Blob([fileData]);
const imageBitmap = await createImageBitmap(blob);

const maxWidth = imageBitmap.width > 750 ? 750 : imageBitmap.width;
const scale = maxWidth / imageBitmap.width;
const targetWidth = maxWidth;
const targetHeight = imageBitmap.height * scale;

const offscreen = new OffscreenCanvas(targetWidth, targetHeight);
const ctx = offscreen.getContext('2d');

if (!ctx) {
toast.error('이미지 μ—…λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.');
return;
}

ctx.drawImage(imageBitmap, 0, 0, targetWidth, targetHeight);

// quality μ„€μ •
const quality = 0.9;
const webpBlob = await offscreen.convertToBlob({
type: 'image/webp',
quality,
});

// λ³€ν™˜λœ Blob을 메인 μŠ€λ ˆλ“œλ‘œ 전솑
// (메인 μŠ€λ ˆλ“œμ—μ„œ File둜 감싸 μ΅œμ’… λ°˜ν™˜)
const arrayBuffer = await webpBlob.arrayBuffer();
self.postMessage({ arrayBuffer, fileName }, [arrayBuffer]);
} catch (error) {
// μ—λŸ¬ λ°œμƒ μ‹œ 메인 μŠ€λ ˆλ“œμ— μ•Œλ¦Ό
self.postMessage({ error: (error as Error).message });
}
};
1 change: 0 additions & 1 deletion src/features/register/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export { dataURLtoFile } from './dataURLToFile';
export { useDragAndDrop } from './useDragAndDrop';
export { useEditableNumberInput } from './useEditableNumberInput';
export { useImageUploader } from './useImageUploader';
53 changes: 24 additions & 29 deletions src/features/register/lib/useDragAndDrop.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,38 @@
import { DragEvent, useState } from 'react';
import { DragEndEvent, DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';

export const useDragAndDrop = (
state: string[],
setState: (images: string[]) => void,
) => {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
import { arrayMove } from '@dnd-kit/sortable';
import { useState } from 'react';

const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
export const useDragAndDrop = (state: string[], setState: (state: string[]) => void) => {
const [activeId, setActiveId] = useState<string | null>(null);

// μ„Όμ„œ μ •μ˜, PointerSensorλŠ” 마우슀, ν„°μΉ˜, 펜 이벀트λ₯Ό λͺ¨λ‘ 컀버
const sensors = useSensors(useSensor(PointerSensor));

const handleDragOver = (e: DragEvent<HTMLDivElement>, index: number) => {
e.preventDefault();
setHoveredIndex(index);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};

const handleDragLeave = () => {
setHoveredIndex(null);
const handleDragCancel = () => {
setActiveId(null);
};

const handleDrop = (index: number) => {
if (draggedIndex !== null && draggedIndex !== index) {
const newImages = [...state];
[newImages[index], newImages[draggedIndex]] = [
newImages[draggedIndex],
newImages[index],
];
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = state.indexOf(active.id as string);
const newIndex = state.indexOf(over.id as string);
const newImages = arrayMove(state, oldIndex, newIndex);
setState(newImages);
}
setDraggedIndex(null);
setHoveredIndex(null);
};

setActiveId(null);
};
return {
activeId,
sensors,
handleDragStart,
handleDragLeave,
handleDragOver,
handleDrop,
hoveredIndex,
handleDragCancel,
handleDragEnd,
};
};
31 changes: 27 additions & 4 deletions src/features/register/lib/useImageUploader.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
import { ChangeEvent, useRef } from 'react';
import { ChangeEvent, useRef, useState } from 'react';
import { compressAndConvertToWebP, convertFileToDataURL } from '../utils';

import { toast } from 'sonner';

export const useImageUploader = (state: string[], setState: (images: string[]) => void) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [progress, setProgress] = useState(0);
const [isReading, setIsReading] = useState(false);
const [_completedCount, setCompletedCount] = useState(0);

const addImages = async (newFiles: File[]) => {
const compressedFiles = await Promise.all(newFiles.map((file) => compressAndConvertToWebP(file)));
const compressedImages = await Promise.all(compressedFiles.map((file) => convertFileToDataURL(file)));
setProgress(0);
setIsReading(true);
setCompletedCount(0);
const totalFiles = newFiles.length;

setState(Array.from(new Set([...state, ...compressedImages])));
const compressedAllFiles = await Promise.all(
newFiles.map(async (file) => {
const compressed = await compressAndConvertToWebP(file);
const dataUrl = await convertFileToDataURL(compressed);

setCompletedCount((prev) => {
const newCompletedCount = prev + 1;
setProgress(Math.round((newCompletedCount / totalFiles) * 100));
return newCompletedCount;
});

return dataUrl;
})
);
setState(Array.from(new Set([...state, ...compressedAllFiles])));
setProgress(100);
setIsReading(false);
};

const handleImage = (e: ChangeEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -41,5 +62,7 @@ export const useImageUploader = (state: string[], setState: (images: string[]) =
deleteImage,
handleImage,
handleBoxClick,
progress,
isReading,
};
};
35 changes: 22 additions & 13 deletions src/features/register/ui/AddImageButton.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,39 @@
import { ProgressCircle } from '@/shared';
import { AiOutlinePlus } from 'react-icons/ai';

interface AddImageButtonProps {
handleBoxClick: () => void;
length: number;
progress: number
isReading: boolean
}

export const AddImageButton = ({ handleBoxClick, length }: AddImageButtonProps) => {
export const AddImageButton = ({ handleBoxClick, length, progress, isReading }: AddImageButtonProps) => {
return (
<button
type="button"
className="flex items-center justify-center w-full h-32 border-2 rounded cursor-pointer web:w-32"
onClick={handleBoxClick}
aria-label="사진 μΆ”κ°€ λ°•μŠ€ λ²„νŠΌ"
>
<div className="flex flex-col items-center justify-center gap-2">
<AiOutlinePlus
aria-label="plus icon"
className=" text-gray2 text-heading2 web:text-heading3"
/>
<div className="text-body1 web:text-[.75rem] flex">
<p aria-label="ν˜„μž¬ 사진 숫자" className="text-cheeseYellow">
{length}
</p>
/5
</div>
</div>
{
isReading
?
<ProgressCircle progress={progress} />
:
<div className="flex flex-col items-center justify-center gap-2">
<AiOutlinePlus
aria-label="plus icon"
className=" text-gray2 text-heading2 web:text-heading3"
/>
<div className="text-body1 web:text-[.75rem] flex">
<p aria-label="ν˜„μž¬ 사진 숫자" className="text-cheeseYellow">
{length}
</p>
/5
</div>
</div>
}
</button>
);
};
49 changes: 49 additions & 0 deletions src/features/register/ui/ImageItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { CSS } from "@dnd-kit/utilities";
import { CarouselItem } from "@/shared";
import DeleteIcon from '@/shared/assets/icons/delete.svg';
import { useSortable } from '@dnd-kit/sortable';

interface ImageItemProps {
image: string;
index: number;
deleteImage: (index: number) => void;
}

export const ImageItem = ({ image, index, deleteImage }: ImageItemProps) => {
// 이 μ•„μ΄ν…œμ΄ μ •λ ¬ κ°€λŠ₯ν•œ ν•­λͺ©μž„을 μ„ μ–Έ
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: image })

// dnd kitκ°€ κ³„μ‚°ν•œ μ’Œν‘œκ°’μ„ DOM에 μ μš©ν•΄ μ‹€μ œ μ•„μ΄ν…œμ΄ 마우슀λ₯Ό 따라 μ›€μ§μ΄κ²Œ ν•œλ‹€.
const style = {
transform: CSS.Translate.toString(transform),
transition,
cursor: "grab",
};

return (
<CarouselItem className='pr-2 basis-1/2 image:basis-1/3' key={image}>
<div
className='relative flex items-center justify-center h-32 mx-3 duration-200'
ref={setNodeRef}
style={style}
{...attributes}

>
<img src={image} alt={`μƒν’ˆ 사진 ${index}`} {...listeners} className='relative object-cover w-full h-full' />
{index === 0 && (
<p className='absolute text-[8px] web:text-xs rounded py-1 px-2 text-white bg-[#454545]/90 top-2 left-1/2 transform -translate-x-1/2'>
λŒ€ν‘œ 사진
</p>
)}
<button
type="button"
className='absolute top-[-5%] right-[-5%] cursor-pointer text-black size-6'
onClick={() => deleteImage(index)}
aria-label={`사진 μ‚­μ œ ${index}`}
>
<img src={DeleteIcon} alt='사진 μ‚­μ œ λ²„νŠΌ' />
</button>
</div>
</CarouselItem >
);
}
Loading