Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5722f75
fix: 리스트 아이템 왼쪽 버튼 클릭 시 todo ↔ done 동작하도록 수정
asksa1256 Aug 6, 2025
4a68509
refactor: bullet onClick, list onClick 이벤트 분리 및 버블링 방지
asksa1256 Aug 6, 2025
cbb201c
fix: button onClick event type 지정 (HTMLButtonElement)
asksa1256 Aug 6, 2025
e053f66
feat: 아이템 클릭 시 상세 페이지로 이동
asksa1256 Aug 6, 2025
d8e4efc
chore: svgr 패키지 컴포넌트 추가, svg 이미지 추가
asksa1256 Aug 6, 2025
bf9060e
chore: svgr 패키지 설치
asksa1256 Aug 6, 2025
0aee11d
refactor: Bullet 버튼 - 컴포넌트로 분리
asksa1256 Aug 6, 2025
3b904b7
feat: ItemDetailPage - 서버 컴포넌트(페이지)와 클라이언트 컴포넌트(수정폼)로 분리
asksa1256 Aug 6, 2025
2039fb7
fix: ItemDetailPage params await 추가 (Next 15 경고)
asksa1256 Aug 6, 2025
cfda7e7
style: 이미지 업로드 버튼 퍼블리싱
asksa1256 Aug 6, 2025
90f6048
chore: 필요없는 pages 파일 제거
asksa1256 Aug 6, 2025
11eae3f
feat: 이미지 업로드 커스텀 훅 생성
asksa1256 Aug 6, 2025
f1d9716
feat: 로딩 스피너 추가
asksa1256 Aug 6, 2025
6491412
chore: TodoLoading 경로 이동 (Loader)
asksa1256 Aug 6, 2025
213ab9a
chore: TodoLoading 경로 이동
asksa1256 Aug 6, 2025
1644195
feat: ImageUploader - 이미지 업로드 및 미리보기 기능 추가
asksa1256 Aug 6, 2025
361deae
style: Memo 컴포넌트 퍼블리싱
asksa1256 Aug 7, 2025
a8d4622
style: 수정 폼 - 수정,삭제 버튼 퍼블리싱
asksa1256 Aug 7, 2025
4bc71cf
feat: debounce 함수 추가
asksa1256 Aug 7, 2025
56a4da3
feat: textarea handleHeight 디바운스 적용 및 언마운트 시 클리어
asksa1256 Aug 7, 2025
53489f0
feat: 폼 수정 payload 세팅
asksa1256 Aug 7, 2025
27e60b5
style: 버튼 클릭 스타일 추가
asksa1256 Aug 9, 2025
f8e2779
style: memo width 변경
asksa1256 Aug 9, 2025
8a06d83
feat: 수정 폼 - 수정 상태에 따라 '수정하기' 버튼 활성화/비활성화 처리
asksa1256 Aug 9, 2025
548af10
chore: 리액트 쿼리 개발자 도구 추가
asksa1256 Aug 11, 2025
fc5407b
refactor: 초기 데이터 요청 구조 prefetchQuery + dehydrate 패턴으로 변경
asksa1256 Aug 11, 2025
a06c884
feat: 수정 폼 - patch api 연동
asksa1256 Aug 11, 2025
cc8378e
feat: ImageUploader onUploaded 전달값 변경 (patch api payload 형식 맞추기)
asksa1256 Aug 11, 2025
084134e
feat: 투두 삭제 api 연동
asksa1256 Aug 11, 2025
e853d43
feat: 수정 폼 - todo/done 상태 업데이트 api 연동
asksa1256 Aug 11, 2025
015b021
style: BulletButton hover시 체크 이미지 표시
asksa1256 Aug 11, 2025
8ca45e0
feat: 할 일 수정 성공 - 목록 페이지로 이동
asksa1256 Aug 11, 2025
064bd0f
style: ImageUploader edit 버튼 hover 스타일 추가
asksa1256 Aug 11, 2025
7de98d8
feat: 이미지 파일 첨부 제약조건 추가
asksa1256 Aug 11, 2025
f474bf5
chore: 불필요한 주석 제거
asksa1256 Aug 11, 2025
d778495
fix: 빌드 에러 수정
asksa1256 Aug 11, 2025
931e1e9
chore: 코드 사이 공백 추가 (변수, 기능 사이)
asksa1256 Aug 12, 2025
c7d195b
chore: next 설정 - 이미지 최적화 옵션 추가
asksa1256 Aug 12, 2025
389e26b
chore: sharp 패키지 추가
asksa1256 Aug 13, 2025
ddacbbb
feat: image blur placeholder 유틸 함수 생성
asksa1256 Aug 13, 2025
798f62a
feat: image blur placeholder 유틸 함수 적용
asksa1256 Aug 13, 2025
d3eda3e
refactor: ImageUploader preview src 삼항 연산 -> 유틸 함수로 분리
asksa1256 Aug 13, 2025
787e540
feat: ImageUploader Image - unoptimized 옵션 추가 (gif 이미지는 최적화 제외)
asksa1256 Aug 13, 2025
a7364d0
refactor: PrefetchHydration wrapper 컴포넌트 생성 및 적용
asksa1256 Aug 13, 2025
4446101
refactor: TodoUpdateForm - 상태 관리 로직, api 요청 로직 커스텀 훅으로 분리
asksa1256 Aug 13, 2025
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
19 changes: 19 additions & 0 deletions app/items/[itemId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import TodoUpdateForm from "@/components/Todos/TodoUpdateForm";
import { getTodo } from "@/lib/api";

interface ItemDetailPageProps {
params: Promise<{ itemId: string }>;
}

const ItemDetailPage = async ({ params }: ItemDetailPageProps) => {
const { itemId } = await params;
const data = await getTodo(itemId);

return (
<section className="mt-10">
<TodoUpdateForm initialData={data} />
</section>
);
};

export default ItemDetailPage;
8 changes: 7 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "@/styles/globals.css";
import Header from "@/components/Header";
import nanumSquare from "@/assets/fonts/NanumSquare/nanumSquare";
import QueryProvider from "./providers/QueryProvider";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

export const metadata: Metadata = {
title: "간편한 투두리스트 - Do it",
Expand All @@ -20,7 +21,12 @@ export default function RootLayout({
<body className={`${nanumSquare.className} bg-gray-50`}>
<Header />
<div className="my-6 md:w-full lg:w-[1200px] lg:mx-auto">
<QueryProvider>{children}</QueryProvider>
<QueryProvider>
{children}
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryProvider>
</div>
</body>
</html>
Expand Down
17 changes: 14 additions & 3 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import TodoClient from "@/components/Todos/TodoClient";
import { getTodos } from "@/lib/api";
import { Suspense } from "react";
import TodoLoading from "@/components/Todos/TodoLoading";
import TodoLoading from "@/components/Loader/TodoLoading";
import {
QueryClient,
HydrationBoundary,
dehydrate,
} from "@tanstack/react-query";

const Home = async () => {
const initialItems = await getTodos();
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["todos"],
queryFn: getTodos,
});

return (
<Suspense fallback={<TodoLoading />}>
<TodoClient initialItems={initialItems} />
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoClient />
</HydrationBoundary>
</Suspense>
);
};
Expand Down
3 changes: 3 additions & 0 deletions assets/images/ico-check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/images/ico-edit.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions assets/images/ico-img.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions assets/images/ico-plus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
44 changes: 44 additions & 0 deletions components/Button/BulletButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import clsx from "clsx";
import Image from "next/image";
import { MouseEvent } from "react";

interface BulletButtonProps {
variant?: string;
type?: "submit" | "button";
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
}

const BulletButton = ({
variant,
type = "button",
onClick,
}: BulletButtonProps) => {
return (
<button
className={clsx("group relative bullet-base", {
"bullet-todo": variant === "todo",
"bullet-done": variant === "done",
})}
onClick={onClick}
type={type}
>
<Image
src="/images/ico-check-wt.svg"
alt="완료된 할 일"
width="20"
height="20"
className={clsx(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-opacity",
{
"opacity-100": variant === "done",
"opacity-0 group-hover:opacity-100": variant === "todo",
}
)}
/>
</button>
);
};

export default BulletButton;
10 changes: 9 additions & 1 deletion components/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ interface ButtonProps {
variant: "primary" | "success" | "danger";
children: ReactNode;
disabled?: boolean;
onClick?: () => void;
}

const Button = ({ type, variant, children, disabled }: ButtonProps) => {
const Button = ({
type,
variant,
children,
disabled,
onClick,
}: ButtonProps) => {
return (
<button
type={type}
Expand All @@ -19,6 +26,7 @@ const Button = ({ type, variant, children, disabled }: ButtonProps) => {
"btn-danger": variant === "danger" && !disabled,
"btn-disabled": disabled,
})}
onClick={onClick}
>
{children}
</button>
Expand Down
120 changes: 120 additions & 0 deletions components/ImageUploader/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"use client";

import { ChangeEvent, useRef, useState } from "react";
import ImgIcon from "@/assets/images/ico-img.svg";
import PlusIcon from "@/assets/images/ico-plus.svg";
import EditIcon from "@/assets/images/ico-edit.svg";
import Image from "next/image";
import useImageUpload from "@/hooks/useImageUpload";
import LoadingSpinner from "@/components/Loader/LoadingSpinner";
import clsx from "clsx";

interface ImageUploaderProps {
initialData?: string;
className: string;
onUploaded: (v: string) => void;
}

const ImageUploader = ({
initialData,
className,
onUploaded,
}: ImageUploaderProps) => {
const fileRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<File | string | null | undefined>(
initialData
);
const { mutate: uploadImage, isPending } = useImageUpload();

const handleClick = () => {
if (!fileRef.current) return;
fileRef.current.click();
};

const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;

const file = files[0];

const filename = file.name.split(".").slice(0, -1).join(".");
const engOnlyRegex = /^[a-zA-Z0-9_\-]+$/;
if (!engOnlyRegex.test(filename)) {
alert("파일 이름은 영어로만 이루어져야 합니다.");
e.target.value = "";
return;
}

const MAX_SIZE = 5 * 1024 * 1024;
if (file.size > MAX_SIZE) {
alert("파일 크기는 5MB 이하여야 합니다.");
e.target.value = "";
return;
}

setPreview(file);

uploadImage(file, {
onSuccess: (image) => {
onUploaded(image.url);
},
onError: () => {
alert("이미지 업로드에 실패했습니다.");
},
});
};

return (
<div
className={`relative flex items-center justify-center w-[384px] h-[310px] rounded-3xl border-2 border-dashed border-gray-300 bg-gray-50 hover:bg-primary-100 hover:border-primary transition-colors overflow-hidden ${className}`}
>
<input
ref={fileRef}
type="file"
className="hidden"
onChange={handleFileChange}
/>

{preview && (
<Image
src={
typeof preview === "string"
? preview
: preview
? URL.createObjectURL(preview)
: ""
}
alt="이미지 미리보기"
width={384}
height={310}
className="absolute object-cover z-[1] w-full h-full pointer-events-none"
/>
)}

<button
type="button"
onClick={handleClick}
disabled={isPending}
className="group relative flex items-center justify-center w-full h-full"
>
<ImgIcon className="w-16 h-16 text-gray-200 group-hover:text-white z-0" />
<span
className={clsx("btn-upload-base", {
"btn-upload": !preview,
"btn-edit": preview,
})}
>
{isPending ? (
<LoadingSpinner />
) : !preview ? (
<PlusIcon className="group-hover:text-white" />
) : (
<EditIcon className="group-hover:text-primary text-white" />
)}
</span>
</button>
</div>
);
};

export default ImageUploader;
8 changes: 3 additions & 5 deletions components/ItemList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const ItemListBase = ({
<ListItemByVariant
key={`${variant}-${item.id}`}
item={item}
onClick={handleClick}
onBulletClick={handleClick}
/>
))}
</ul>
Expand All @@ -89,7 +89,7 @@ const ItemListBase = ({
};

const ItemList = {
Todo: ({ items, onClick }: ItemListProps) => {
Todo: ({ items }: ItemListProps) => {
return (
<ItemListBase
items={items}
Expand All @@ -103,11 +103,10 @@ const ItemList = {
TODO를 새롭게 추가해주세요!
</>
}
onClick={onClick}
/>
);
},
Done: ({ items, onClick }: ItemListProps) => {
Done: ({ items }: ItemListProps) => {
return (
<ItemListBase
items={items}
Expand All @@ -121,7 +120,6 @@ const ItemList = {
해야 할 일을 체크해보세요!
</>
}
onClick={onClick}
/>
);
},
Expand Down
48 changes: 25 additions & 23 deletions components/ListItem/index.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,44 @@
import Image from "next/image";
import { ItemProps } from "@/types/todo";
import { MouseEvent } from "react";
import { ItemProps, Item } from "@/types/todo";
import clsx from "clsx";
import { useRouter } from "next/navigation";
import BulletButton from "@/components/Button/BulletButton";

const ListItemBase = ({ item, variant = "todo", onBulletClick }: ItemProps) => {
const router = useRouter();

const handleBulletClick = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onBulletClick(item);
};

const handleListClick = (item: Item) => {
router.push(`/items/${item.id}`);
};

const ListItemBase = ({ item, variant = "todo", onClick }: ItemProps) => {
return (
<li
className={clsx("item-base", {
"item-todo": variant === "todo",
"item-done": variant === "done",
})}
onClick={() => onClick(item)}
onClick={() => handleListClick(item)}
>
<span
className={clsx("bullet-base", {
"bullet-todo": variant === "todo",
"bullet-done": variant === "done",
})}
>
{variant === "done" && (
<Image
src="/images/ico-check-wt.svg"
alt="완료된 할 일"
width="20"
height="20"
/>
)}
</span>
<BulletButton
variant={variant}
onClick={handleBulletClick}
></BulletButton>
{item.name}
</li>
);
};

const ListItem = {
Todo: ({ item, onClick }: ItemProps) => (
<ListItemBase item={item} variant="todo" onClick={onClick} />
Todo: ({ item, onBulletClick }: ItemProps) => (
<ListItemBase item={item} variant="todo" onBulletClick={onBulletClick} />
),
Done: ({ item, onClick }: ItemProps) => (
<ListItemBase item={item} variant="done" onClick={onClick} />
Done: ({ item, onBulletClick }: ItemProps) => (
<ListItemBase item={item} variant="done" onBulletClick={onBulletClick} />
),
};

Expand Down
7 changes: 7 additions & 0 deletions components/Loader/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const LoadingSpinner = () => {
return (
<div className="w-8 h-8 border-4 border-gray-300 border-t-primary rounded-full animate-spin" />
);
};

export default LoadingSpinner;
File renamed without changes.
Loading