Skip to content

Commit c77d471

Browse files
Merge pull request #248 from summerDev96/Next-배수민-sprint10
[배수민] sprint10
2 parents abc1228 + d23af23 commit c77d471

19 files changed

Lines changed: 492 additions & 17 deletions

next.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
const path = require('path');
33

44
const nextConfig = {
5+
images: {
6+
remotePatterns: [
7+
{
8+
protocol: 'https',
9+
hostname: 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com',
10+
pathname: '/**', // 모든 경로 허용
11+
},
12+
],
13+
},
514
reactStrictMode: true,
615
webpack: (config) => {
716
// alias 추가

src/app/api/clientApiClient.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const clientApiClient = axios.create({
55
timeout: 10_000,
66
headers: { 'Content-Type': 'application/json' },
77
});
8-
98
// 에러 처리
109
clientApiClient.interceptors.response.use(
1110
(res) => res.data,

src/app/api/todo.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Item,
55
ItemDetail,
66
UpdateItemRequest,
7+
UploadImageResponse,
78
} from '@/types/TodoTypes';
89

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

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

2021
export const getItemListServer = () => {
@@ -32,3 +33,14 @@ export const updateItem = (itemId: number, data: UpdateItemRequest): Promise<Ite
3233
export const deleteItem = (itemId: number): Promise<DeleteItemResponse> => {
3334
return clientApiClient.delete(`${process.env.NEXT_PUBLIC_TENANT_ID}/items/${itemId}`);
3435
};
36+
37+
export const uploadImage = (file: File): Promise<UploadImageResponse> => {
38+
const formData = new FormData();
39+
formData.append('image', file);
40+
41+
return clientApiClient.post(`${process.env.NEXT_PUBLIC_TENANT_ID}/images/upload`, formData, {
42+
headers: {
43+
'Content-Type': 'multipart/form-data',
44+
},
45+
});
46+
};
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
5+
import { useMutation, useQuery } from '@tanstack/react-query';
6+
import { useRouter } from 'next/navigation';
7+
8+
import Button from '@/components/Button';
9+
import CheckListDetail from '@/components/CheckListDetail';
10+
import ImageUploader from '@/components/ImageUploader';
11+
import LoadingOverlay from '@/components/LoadingOverlay';
12+
import Memo from '@/components/Memo';
13+
import { useItemStore } from '@/store/itemStore';
14+
import { UploadImageResponse } from '@/types/TodoTypes';
15+
16+
import { deleteItem, getItem, updateItem, uploadImage } from '../api/todo';
17+
18+
interface ItemDetailContentProps {
19+
itemId: number;
20+
}
21+
22+
const ItemDetailContent = ({ itemId }: ItemDetailContentProps) => {
23+
const router = useRouter();
24+
const { data, isLoading, isFetching } = useQuery({
25+
queryKey: ['itemDetail', itemId],
26+
queryFn: () => getItem(itemId),
27+
});
28+
29+
const updateItemMutation = useMutation({
30+
mutationFn: () => updateItem(itemId, detailData),
31+
retry: 1,
32+
retryDelay: 300,
33+
onSuccess: () => {
34+
setDetailData({ name: '', memo: '', imageUrl: '', isCompleted: false });
35+
router.push('/');
36+
},
37+
onError: (error) => {
38+
console.log(error);
39+
},
40+
});
41+
42+
const uploadImageMutation = useMutation<UploadImageResponse, Error, File>({
43+
mutationFn: (file: File) => uploadImage(file),
44+
retry: 1,
45+
retryDelay: 300,
46+
onSuccess: (response) => {
47+
const { url } = response;
48+
setDetailData({ imageUrl: url });
49+
updateItemMutation.mutate();
50+
},
51+
onError: (error) => {
52+
console.log(error);
53+
},
54+
});
55+
56+
const deleteItemMutation = useMutation({
57+
mutationFn: () => deleteItem(itemId),
58+
retry: 1,
59+
retryDelay: 300,
60+
onSuccess: () => {
61+
setDetailData({ name: '', memo: '', imageUrl: '', isCompleted: false });
62+
router.push('/');
63+
},
64+
onError: (error) => {
65+
console.log(error);
66+
},
67+
});
68+
69+
const { detailData, setDetailData } = useItemStore();
70+
71+
const [file, setFile] = useState<File | null>(null);
72+
73+
const disableEditButton =
74+
!detailData.name ||
75+
(detailData.isCompleted === data?.isCompleted &&
76+
detailData.name === data?.name &&
77+
detailData.memo === data?.memo &&
78+
!file);
79+
80+
const onUploadFile = (fileData: File) => {
81+
setFile(fileData);
82+
};
83+
84+
const onClickEditDetail = () => {
85+
if (file) {
86+
uploadImageMutation.mutate(file);
87+
} else {
88+
updateItemMutation.mutate();
89+
}
90+
};
91+
92+
useEffect(() => {
93+
if (data) {
94+
const { name, memo, imageUrl, isCompleted } = data;
95+
setDetailData({ name, memo, imageUrl, isCompleted });
96+
}
97+
}, [data, setDetailData]);
98+
99+
if (isLoading || isFetching) return <LoadingOverlay />;
100+
101+
return (
102+
<div className='max-w-[75rem] w-full flex flex-col gap-6.5 bg-white px-12 sm:px-24.5 py-6'>
103+
<CheckListDetail />
104+
<div className='flex flex-col sm:flex-row items-start justify-center gap-6'>
105+
<ImageUploader imageUrl={detailData.imageUrl} onUpload={onUploadFile} />
106+
<Memo />
107+
</div>
108+
<div className='flex w-full justify-center sm:justify-end gap-4 min-w-0'>
109+
<Button
110+
mode='edit'
111+
size='full'
112+
className='max-w-[168px]'
113+
disabled={disableEditButton}
114+
onClick={onClickEditDetail}
115+
>
116+
수정 완료
117+
</Button>
118+
<Button
119+
mode='delete'
120+
size='full'
121+
className='max-w-[168px]'
122+
onClick={() => deleteItemMutation.mutate()}
123+
>
124+
삭제하기
125+
</Button>
126+
</div>
127+
</div>
128+
);
129+
};
130+
131+
export default ItemDetailContent;

src/app/globals.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,31 @@
4141
margin: 0;
4242
}
4343

44+
/* 컴포넌트 전용 스크롤바 */
45+
.scrollbar-custom::-webkit-scrollbar {
46+
width: 8px;
47+
height: 8px;
48+
}
49+
50+
.scrollbar-custom::-webkit-scrollbar-thumb {
51+
background-color: transparent;
52+
border-radius: 4px;
53+
}
54+
55+
.scrollbar-custom::-webkit-scrollbar-track {
56+
background-color: transparent;
57+
}
58+
59+
.scrollbar-custom::-webkit-scrollbar-button {
60+
display: none; /* 화살표 제거 */
61+
}
62+
63+
/* Firefox */
64+
.scrollbar-custom {
65+
scrollbar-width: thin;
66+
scrollbar-color: #fde68a transparent;
67+
}
68+
4469
html,
4570
body {
4671
max-width: 100vw;

src/app/items/[itemId]/page.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { getItem } from '@/app/api/todo';
2+
import ItemDetailContent from '@/app/components/ItemDetailContent';
3+
import HydrationWrapper from '@/components/HydrationWrapper';
4+
5+
interface ItemPageProps {
6+
params: Promise<{ itemId: string }>;
7+
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
8+
}
9+
const ItemPage = async ({ params }: ItemPageProps) => {
10+
const { itemId } = await params;
11+
return (
12+
<HydrationWrapper
13+
prefetchQueries={[
14+
{ queryKey: ['itemDetail', itemId], queryFn: () => getItem(Number(itemId)) },
15+
]}
16+
>
17+
<div className='flex justify-center bg-gray-50 min-h-[calc(100vh-3.75rem)]'>
18+
<ItemDetailContent itemId={Number(itemId)} />
19+
</div>
20+
</HydrationWrapper>
21+
);
22+
};
23+
24+
export default ItemPage;

src/assets/images/memo.png

2.22 KB
Loading

src/assets/images/no_image.png

1.46 KB
Loading

src/components/Button.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ import X from '@/assets/icons/x.svg';
99
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
1010
disabled?: boolean;
1111
mode?: 'add' | 'delete' | 'edit';
12-
size?: 'small' | 'large';
12+
size?: 'small' | 'large' | 'full';
1313
children?: React.ReactNode;
1414
className?: string;
1515
}
1616

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

2020
const SIZE_CLASS = {
2121
small: 'p-4',
2222
large: 'min-w-41 gap-1 py-3.5 px-10',
23+
full: 'w-full p-4',
2324
} as const;
2425

2526
const Button = ({
@@ -53,8 +54,8 @@ const Button = ({
5354
className={`${disabled ? 'stroke-slate-900' : 'stroke-white'}`}
5455
/>
5556
)}
56-
{mode === 'delete' && <X className='stroke-slate-900' />}
57-
{mode === 'edit' && <Check className='stroke-slate-900' />}
57+
{mode === 'delete' && <X width={16} height={16} className='stroke-white' />}
58+
{mode === 'edit' && <Check width={16} height={16} className='stroke-slate-900' />}
5859
{children}
5960
</button>
6061
);

src/components/CheckListDetail.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use client';
2+
import { useState } from 'react';
3+
4+
import clsx from 'clsx';
5+
6+
import Check from '@/assets/icons/check.svg';
7+
import { useItemStore } from '@/store/itemStore';
8+
9+
interface CheckListDetailProps {
10+
className?: string;
11+
}
12+
13+
const CheckListDetail = ({ className }: CheckListDetailProps) => {
14+
const [editMode, setEditMode] = useState(false);
15+
const { detailData, setDetailData } = useItemStore();
16+
17+
const onChangeCheck = (e: React.ChangeEvent<HTMLInputElement>) => {
18+
e.stopPropagation();
19+
setDetailData({ isCompleted: !detailData.isCompleted });
20+
};
21+
22+
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
23+
setDetailData({ name: e.target.value });
24+
};
25+
26+
const onClickDetail = () => {
27+
setEditMode(true);
28+
};
29+
30+
const Checkbox = () => {
31+
return (
32+
<label
33+
className='flex items-center cursor-pointer select-none'
34+
onClick={(e) => e.stopPropagation()}
35+
>
36+
<input
37+
type='checkbox'
38+
className='hidden'
39+
checked={detailData.isCompleted}
40+
onChange={onChangeCheck}
41+
/>
42+
<div
43+
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
44+
`}
45+
>
46+
{detailData.isCompleted && <Check stroke='white' width={16} height={16} />}
47+
</div>
48+
</label>
49+
);
50+
};
51+
52+
return (
53+
<div
54+
className={clsx(
55+
'w-full border-2 border-slate-900 rounded-[1.5rem] px-3 py-3.5 flex gap-4 items-center justify-center',
56+
detailData.isCompleted ? 'bg-violet-100' : 'bg-white',
57+
className,
58+
)}
59+
onClick={onClickDetail}
60+
>
61+
<Checkbox />
62+
{editMode ? (
63+
<input
64+
value={detailData.name}
65+
onChange={handleNameChange}
66+
className='focus:outline-none'
67+
style={{ width: `${detailData.name.length + 1.4 || 1}ch` }}
68+
onKeyDown={(e) => {
69+
if (e.key === 'Enter') {
70+
setEditMode(false);
71+
}
72+
}}
73+
/>
74+
) : (
75+
<span className='truncate underline'>{detailData.name}</span>
76+
)}
77+
</div>
78+
);
79+
};
80+
81+
export default CheckListDetail;

0 commit comments

Comments
 (0)