Skip to content
Merged
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
9 changes: 9 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
const path = require('path');

const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com',
pathname: '/**', // 모든 경로 허용
},
],
},
reactStrictMode: true,
webpack: (config) => {
// alias 추가
Expand Down
1 change: 0 additions & 1 deletion src/app/api/clientApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ const clientApiClient = axios.create({
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
});

// 에러 처리
clientApiClient.interceptors.response.use(
(res) => res.data,
Expand Down
14 changes: 13 additions & 1 deletion src/app/api/todo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Item,
ItemDetail,
UpdateItemRequest,
UploadImageResponse,
} from '@/types/TodoTypes';

import clientApiClient from './clientApiClient';
Expand All @@ -14,7 +15,7 @@ export const addItem = (data: AddItemRequest): Promise<ItemDetail> => {
};

export const getItemList = (): Promise<Item[]> => {
return clientApiClient.get(`${process.env.NEXT_PUBLIC_TENANT_ID}/items`, {});
return clientApiClient.get(`${process.env.NEXT_PUBLIC_TENANT_ID}/items`);
};

export const getItemListServer = () => {
Expand All @@ -32,3 +33,14 @@ export const updateItem = (itemId: number, data: UpdateItemRequest): Promise<Ite
export const deleteItem = (itemId: number): Promise<DeleteItemResponse> => {
return clientApiClient.delete(`${process.env.NEXT_PUBLIC_TENANT_ID}/items/${itemId}`);
};

export const uploadImage = (file: File): Promise<UploadImageResponse> => {
const formData = new FormData();
formData.append('image', file);

return clientApiClient.post(`${process.env.NEXT_PUBLIC_TENANT_ID}/images/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
131 changes: 131 additions & 0 deletions src/app/components/ItemDetailContent.tsx
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data fetching 로직이 길어지는데 따로 로직만 분리해 커스텀 훅을 만들어 관리해보는건 어떨까요?

Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
'use client';

import { useEffect, useState } from 'react';

import { useMutation, useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';

import Button from '@/components/Button';
import CheckListDetail from '@/components/CheckListDetail';
import ImageUploader from '@/components/ImageUploader';
import LoadingOverlay from '@/components/LoadingOverlay';
import Memo from '@/components/Memo';
import { useItemStore } from '@/store/itemStore';
import { UploadImageResponse } from '@/types/TodoTypes';

import { deleteItem, getItem, updateItem, uploadImage } from '../api/todo';

interface ItemDetailContentProps {
itemId: number;
}

const ItemDetailContent = ({ itemId }: ItemDetailContentProps) => {
const router = useRouter();
const { data, isLoading, isFetching } = useQuery({
queryKey: ['itemDetail', itemId],
queryFn: () => getItem(itemId),
});

const updateItemMutation = useMutation({
mutationFn: () => updateItem(itemId, detailData),
retry: 1,
retryDelay: 300,
onSuccess: () => {
setDetailData({ name: '', memo: '', imageUrl: '', isCompleted: false });
router.push('/');
},
onError: (error) => {
console.log(error);
},
});

const uploadImageMutation = useMutation<UploadImageResponse, Error, File>({
mutationFn: (file: File) => uploadImage(file),
retry: 1,
retryDelay: 300,
onSuccess: (response) => {
const { url } = response;
setDetailData({ imageUrl: url });
updateItemMutation.mutate();
},
onError: (error) => {
console.log(error);
},
});

const deleteItemMutation = useMutation({
mutationFn: () => deleteItem(itemId),
retry: 1,
retryDelay: 300,
onSuccess: () => {
setDetailData({ name: '', memo: '', imageUrl: '', isCompleted: false });
router.push('/');
},
onError: (error) => {
console.log(error);
},
});

const { detailData, setDetailData } = useItemStore();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리액트 쿼리를 사용하고있어 쿼리 키 기반 캐싱을 활용해 상세 데이터를 조회하는 쿼리를 필요한 컴포넌트에서 직접 작성하는게 좋을것같은데, 별도의 상태 관리가 필요한 이유가 있을까요~?


const [file, setFile] = useState<File | null>(null);

const disableEditButton =
!detailData.name ||
(detailData.isCompleted === data?.isCompleted &&
detailData.name === data?.name &&
detailData.memo === data?.memo &&
!file);

const onUploadFile = (fileData: File) => {
setFile(fileData);
};

const onClickEditDetail = () => {
if (file) {
uploadImageMutation.mutate(file);
} else {
updateItemMutation.mutate();
}
};

useEffect(() => {
if (data) {
const { name, memo, imageUrl, isCompleted } = data;
setDetailData({ name, memo, imageUrl, isCompleted });
}
}, [data, setDetailData]);
Comment on lines +92 to +97
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

별도의 상태관리가 필요하지않다면 이 effect도 없앨 수 있을 것 같아요 :)


if (isLoading || isFetching) return <LoadingOverlay />;

return (
<div className='max-w-[75rem] w-full flex flex-col gap-6.5 bg-white px-12 sm:px-24.5 py-6'>
<CheckListDetail />
<div className='flex flex-col sm:flex-row items-start justify-center gap-6'>
<ImageUploader imageUrl={detailData.imageUrl} onUpload={onUploadFile} />
<Memo />
</div>
<div className='flex w-full justify-center sm:justify-end gap-4 min-w-0'>
<Button
mode='edit'
size='full'
className='max-w-[168px]'
disabled={disableEditButton}
onClick={onClickEditDetail}
>
수정 완료
</Button>
<Button
mode='delete'
size='full'
className='max-w-[168px]'
onClick={() => deleteItemMutation.mutate()}
>
삭제하기
</Button>
</div>
</div>
);
};

export default ItemDetailContent;
25 changes: 25 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@
margin: 0;
}

/* 컴포넌트 전용 스크롤바 */
.scrollbar-custom::-webkit-scrollbar {
width: 8px;
height: 8px;
}

.scrollbar-custom::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 4px;
}

.scrollbar-custom::-webkit-scrollbar-track {
background-color: transparent;
}

.scrollbar-custom::-webkit-scrollbar-button {
display: none; /* 화살표 제거 */
}

/* Firefox */
.scrollbar-custom {
scrollbar-width: thin;
scrollbar-color: #fde68a transparent;
}

html,
body {
max-width: 100vw;
Expand Down
24 changes: 24 additions & 0 deletions src/app/items/[itemId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getItem } from '@/app/api/todo';
import ItemDetailContent from '@/app/components/ItemDetailContent';
import HydrationWrapper from '@/components/HydrationWrapper';

interface ItemPageProps {
params: Promise<{ itemId: string }>;
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id의 경우 string이 아닌 number타입으로 들어가는 경우도 있을까요?

}
const ItemPage = async ({ params }: ItemPageProps) => {
const { itemId } = await params;
return (
<HydrationWrapper
prefetchQueries={[
{ queryKey: ['itemDetail', itemId], queryFn: () => getItem(Number(itemId)) },
]}
>
<div className='flex justify-center bg-gray-50 min-h-[calc(100vh-3.75rem)]'>
<ItemDetailContent itemId={Number(itemId)} />
</div>
</HydrationWrapper>
);
};

export default ItemPage;
Binary file added src/assets/images/memo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/no_image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 5 additions & 4 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ import X from '@/assets/icons/x.svg';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
disabled?: boolean;
mode?: 'add' | 'delete' | 'edit';
size?: 'small' | 'large';
size?: 'small' | 'large' | 'full';
children?: React.ReactNode;
className?: string;
}

const BASE_CLASS =
'flex items-center text-base font-bold rounded-[1.68rem] border-2 border-slate-900 shadow-offset' as const;
'flex items-center justify-center text-base font-bold rounded-[1.68rem] border-2 border-slate-900 shadow-offset' as const;

const SIZE_CLASS = {
small: 'p-4',
large: 'min-w-41 gap-1 py-3.5 px-10',
full: 'w-full p-4',
} as const;

const Button = ({
Expand Down Expand Up @@ -53,8 +54,8 @@ const Button = ({
className={`${disabled ? 'stroke-slate-900' : 'stroke-white'}`}
/>
)}
{mode === 'delete' && <X className='stroke-slate-900' />}
{mode === 'edit' && <Check className='stroke-slate-900' />}
{mode === 'delete' && <X width={16} height={16} className='stroke-white' />}
{mode === 'edit' && <Check width={16} height={16} className='stroke-slate-900' />}
{children}
</button>
);
Expand Down
81 changes: 81 additions & 0 deletions src/components/CheckListDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';
import { useState } from 'react';

import clsx from 'clsx';

import Check from '@/assets/icons/check.svg';
import { useItemStore } from '@/store/itemStore';

interface CheckListDetailProps {
className?: string;
}

const CheckListDetail = ({ className }: CheckListDetailProps) => {
const [editMode, setEditMode] = useState(false);
const { detailData, setDetailData } = useItemStore();

const onChangeCheck = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
setDetailData({ isCompleted: !detailData.isCompleted });
};

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setDetailData({ name: e.target.value });
};

const onClickDetail = () => {
setEditMode(true);
};

const Checkbox = () => {
return (
<label
className='flex items-center cursor-pointer select-none'
onClick={(e) => e.stopPropagation()}
>
<input
type='checkbox'
className='hidden'
checked={detailData.isCompleted}
onChange={onChangeCheck}
/>
<div
className={`${detailData.isCompleted ? 'border-none bg-violet-600' : 'border-slate-900 border-2 bg-yellow-50'} w-8 h-8 rounded-full border-gray-400 flex items-center justify-center
`}
>
{detailData.isCompleted && <Check stroke='white' width={16} height={16} />}
</div>
</label>
);
};

return (
<div
className={clsx(
'w-full border-2 border-slate-900 rounded-[1.5rem] px-3 py-3.5 flex gap-4 items-center justify-center',
detailData.isCompleted ? 'bg-violet-100' : 'bg-white',
className,
)}
onClick={onClickDetail}
>
<Checkbox />
{editMode ? (
<input
value={detailData.name}
onChange={handleNameChange}
className='focus:outline-none'
style={{ width: `${detailData.name.length + 1.4 || 1}ch` }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setEditMode(false);
}
}}
/>
) : (
<span className='truncate underline'>{detailData.name}</span>
)}
Comment on lines +62 to +76
Copy link
Collaborator Author

@summerDev96 summerDev96 Aug 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CheckListDetail 컴포넌트에서 체크 리스트 이름 수정 시 length를 통해 길이를 맞추려고 하였는데, 더 좋은 방법이 있을까요? 읽기 모드일때는 span으로 보여주고, 수정 모드일때는 input으로 보여주다보니 레이아웃이 틀어지는 것 같습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고정 너비를 가진 컨테이너로 감싸서 input과 span이 동일한 공간을 차지하도록해주면 해결될것같네요! :) 너비를 고정하기 애매하다면, flex-1 min-w-0을 사용해 컨테이너가 남은 공간을 모두 차지하게 할 수 있어요.

</div>
);
};

export default CheckListDetail;
Loading
Loading