diff --git a/next.config.js b/next.config.js index 87642a2f..9c1a4c56 100644 --- a/next.config.js +++ b/next.config.js @@ -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 추가 diff --git a/src/app/api/clientApiClient.ts b/src/app/api/clientApiClient.ts index 8c1b02cc..9ff708af 100644 --- a/src/app/api/clientApiClient.ts +++ b/src/app/api/clientApiClient.ts @@ -5,7 +5,6 @@ const clientApiClient = axios.create({ timeout: 10_000, headers: { 'Content-Type': 'application/json' }, }); - // 에러 처리 clientApiClient.interceptors.response.use( (res) => res.data, diff --git a/src/app/api/todo.ts b/src/app/api/todo.ts index 66132b47..5a912b06 100644 --- a/src/app/api/todo.ts +++ b/src/app/api/todo.ts @@ -4,6 +4,7 @@ import { Item, ItemDetail, UpdateItemRequest, + UploadImageResponse, } from '@/types/TodoTypes'; import clientApiClient from './clientApiClient'; @@ -14,7 +15,7 @@ export const addItem = (data: AddItemRequest): Promise => { }; export const getItemList = (): Promise => { - return clientApiClient.get(`${process.env.NEXT_PUBLIC_TENANT_ID}/items`, {}); + return clientApiClient.get(`${process.env.NEXT_PUBLIC_TENANT_ID}/items`); }; export const getItemListServer = () => { @@ -32,3 +33,14 @@ export const updateItem = (itemId: number, data: UpdateItemRequest): Promise => { return clientApiClient.delete(`${process.env.NEXT_PUBLIC_TENANT_ID}/items/${itemId}`); }; + +export const uploadImage = (file: File): Promise => { + 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', + }, + }); +}; diff --git a/src/app/components/ItemDetailContent.tsx b/src/app/components/ItemDetailContent.tsx new file mode 100644 index 00000000..0de70916 --- /dev/null +++ b/src/app/components/ItemDetailContent.tsx @@ -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({ + 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(); + + const [file, setFile] = useState(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]); + + if (isLoading || isFetching) return ; + + return ( +
+ +
+ + +
+
+ + +
+
+ ); +}; + +export default ItemDetailContent; diff --git a/src/app/globals.css b/src/app/globals.css index cef79d5b..13598811 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/items/[itemId]/page.tsx b/src/app/items/[itemId]/page.tsx new file mode 100644 index 00000000..4301e8d3 --- /dev/null +++ b/src/app/items/[itemId]/page.tsx @@ -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 }>; +} +const ItemPage = async ({ params }: ItemPageProps) => { + const { itemId } = await params; + return ( + getItem(Number(itemId)) }, + ]} + > +
+ +
+
+ ); +}; + +export default ItemPage; diff --git a/src/assets/images/memo.png b/src/assets/images/memo.png new file mode 100644 index 00000000..11e2e5c3 Binary files /dev/null and b/src/assets/images/memo.png differ diff --git a/src/assets/images/no_image.png b/src/assets/images/no_image.png new file mode 100644 index 00000000..f94fd474 Binary files /dev/null and b/src/assets/images/no_image.png differ diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 6f4ad5a8..55bbca2e 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -9,17 +9,18 @@ import X from '@/assets/icons/x.svg'; interface ButtonProps extends ButtonHTMLAttributes { 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 = ({ @@ -53,8 +54,8 @@ const Button = ({ className={`${disabled ? 'stroke-slate-900' : 'stroke-white'}`} /> )} - {mode === 'delete' && } - {mode === 'edit' && } + {mode === 'delete' && } + {mode === 'edit' && } {children} ); diff --git a/src/components/CheckListDetail.tsx b/src/components/CheckListDetail.tsx new file mode 100644 index 00000000..d0a296a3 --- /dev/null +++ b/src/components/CheckListDetail.tsx @@ -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) => { + e.stopPropagation(); + setDetailData({ isCompleted: !detailData.isCompleted }); + }; + + const handleNameChange = (e: React.ChangeEvent) => { + setDetailData({ name: e.target.value }); + }; + + const onClickDetail = () => { + setEditMode(true); + }; + + const Checkbox = () => { + return ( + + ); + }; + + return ( +
+ + {editMode ? ( + { + if (e.key === 'Enter') { + setEditMode(false); + } + }} + /> + ) : ( + {detailData.name} + )} +
+ ); +}; + +export default CheckListDetail; diff --git a/src/components/CheckListItem.tsx b/src/components/CheckListItem.tsx index a347645a..2ac17c3d 100644 --- a/src/components/CheckListItem.tsx +++ b/src/components/CheckListItem.tsx @@ -1,3 +1,4 @@ +'use client'; import clsx from 'clsx'; import Check from '@/assets/icons/check.svg'; @@ -7,18 +8,29 @@ interface CheckListItemProps { checked?: boolean; className?: string; disabled?: boolean; + onCheck?: () => void; onClick?: () => void; } -const CheckListItem = ({ name, checked, className, disabled, onClick }: CheckListItemProps) => { +const CheckListItem = ({ + name, + checked, + className, + disabled, + onCheck, + onClick, +}: CheckListItemProps) => { const onChangeCheck = (e: React.ChangeEvent) => { e.stopPropagation(); - onClick?.(); + onCheck?.(); }; const Checkbox = () => { return ( -