Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cad48bd
chore: 안쓰는 변수 제거
SanginJeong Dec 1, 2025
7a44ccd
fix: 카드 아이템 높이 및 정렬 맞추기
SanginJeong Dec 1, 2025
2302e05
refactor: 페이지 네이션 endPage를 넘어갈 때 현재 page를 맨 앞으로 수정
SanginJeong Dec 1, 2025
b71e23a
refactor: formatClampledCount 적용
SanginJeong Dec 1, 2025
0b7eea6
refactor: Modal, Dropdown CreatePoratl로 변경
SanginJeong Dec 1, 2025
17bc1e7
refactor: 게시글 -> 목록으로 돌아가기 구현
SanginJeong Dec 1, 2025
90292ce
refactor: 글쓰기 유효성 검사 추가
SanginJeong Dec 1, 2025
74e29e0
refactor: 할 일 추가 유효성 검사 및 모달 위치 변경
SanginJeong Dec 1, 2025
edd02e4
refactor: 팀 수정하기 유효성 검사
SanginJeong Dec 1, 2025
41c7cef
feat: 게시글 수정 hooks 구현
SanginJeong Dec 1, 2025
c4ee3a2
feat: 게시물 수정하기 구현
SanginJeong Dec 1, 2025
26d4b7c
refactor: 글쓰기 페이지 useImageUpload 훅 사용버전으로 변경
SanginJeong Dec 1, 2025
71fe892
refactor: 글쓰기 후 목록으로 돌아왔을 때 최신화 안되는 버그 수정
SanginJeong Dec 1, 2025
4980d66
refactor: 팀 이미지 수정하기 리팩토링
SanginJeong Dec 2, 2025
7eeeb84
feat: 할 일 편집 유효성 검사 추가
SanginJeong Dec 2, 2025
ff3b8b8
feat: 게시물 상세 페이지 이미지 등록
SanginJeong Dec 2, 2025
4faf5c0
fix: 허용되지 않은 URL 이미지 렌더링 검사
SanginJeong Dec 2, 2025
c5d386d
feat: 무한 스크롤 시 애니메이션 구현
SanginJeong Dec 2, 2025
9ca4658
Merge branch 'develop' of https://github.com/sprint18-4-4/Coworkers i…
SanginJeong Dec 2, 2025
5bb5330
Merge branch 'develop' of https://github.com/sprint18-4-4/Coworkers i…
SanginJeong Dec 2, 2025
25da66c
feat: 서버 컴포넌트들에 정적 메타데이터 추가
SanginJeong Dec 2, 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
11 changes: 11 additions & 0 deletions src/api/axios/article/_type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,14 @@ export interface PostArticleRequest {
}

export type PostArticleResponse = ArticleListItem;

export interface PatchArticleRequest {
articleId: number;
body: {
image?: string | null;
content?: string;
title?: string;
};
}

export type PatchArticleResponse = ArticleDetail;
9 changes: 9 additions & 0 deletions src/api/axios/article/patchArticle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { instance } from "@/lib";
import { PatchArticleRequest, PatchArticleResponse } from "./_type";

const patchArticle = async ({ articleId, body }: PatchArticleRequest) => {
const { data } = await instance.patch<PatchArticleResponse>(`/articles/${articleId}`, body);
return data;
};

export default patchArticle;
1 change: 1 addition & 0 deletions src/api/axios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ export { default as deleteArticleComment } from "./articleComment/deleteArticleC
export { default as patchArticleComment } from "./articleComment/patchArticleComment";
export { default as getInvitation } from "./group/getInvitation";
export { default as deleteMember } from "./group/deleteMember";
export { default as patchArticle } from "./article/patchArticle";
35 changes: 35 additions & 0 deletions src/api/hooks/article/useGetArticlesInfinite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { getArticles } from "@/api/axios";
import { GetArticlesRequest } from "@/api/axios/article/_type";
import { useInfiniteQuery } from "@tanstack/react-query";

type UseGetArticlesInfiniteParams = Omit<GetArticlesRequest, "page"> & {
pageSize?: number;
};

const useGetArticlesInfinite = (params: UseGetArticlesInfiniteParams) => {
const { pageSize = 6, ...rest } = params;

return useInfiniteQuery({
queryKey: ["articles", rest],
queryFn: ({ pageParam = 1 }) =>
getArticles({
...rest,
page: pageParam,
pageSize,
}),
initialPageParam: 1,

getNextPageParam: (lastPage, allPages) => {
const loadedCount = allPages.reduce((acc, page) => acc + page.list.length, 0);

return loadedCount < lastPage.totalCount ? allPages.length + 1 : undefined;
},

staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 60 * 24,
});
};

export default useGetArticlesInfinite;
23 changes: 23 additions & 0 deletions src/api/hooks/article/usePatchArticle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { patchArticle } from "@/api/axios";
import { toastKit } from "@/utils";
import { useMutation, useQueryClient } from "@tanstack/react-query";

const usePatchArticle = () => {
const { success, error } = toastKit();
const queryClient = useQueryClient();

return useMutation({
mutationKey: ["patchArticle"],
mutationFn: patchArticle,
onSuccess: (data) => {
const articleId = data.id;
success("게시물을 성공적으로 수정하였습니다.");
queryClient.invalidateQueries({ queryKey: ["article", articleId] });
},
onError: () => {
error("게시물을 수정하지 못하였습니다.");
},
});
};

export default usePatchArticle;
6 changes: 4 additions & 2 deletions src/api/hooks/article/usePostArticle.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { postArticle } from "@/api/axios";
import { toastKit } from "@/utils";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";

const usePostArticle = () => {
const router = useRouter();
const queryClient = useQueryClient();
const { success, error } = toastKit();
return useMutation({
mutationFn: postArticle,
onSuccess: (data) => {
success("게시물 등록을 성공했습니다.");
router.push(`/dashboard/${data.id}`);
queryClient.invalidateQueries({ queryKey: ["articles"] });
router.replace(`/dashboard/${data.id}`);
},
onError: () => {
error("게시물을 등록하지 못하였습니다.");
Expand Down
3 changes: 0 additions & 3 deletions src/api/hooks/group/usePatchGroup.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import { patchGroup } from "@/api/axios";
import { toastKit } from "@/utils";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";

const usePatchGroup = () => {
const { success, error } = toastKit();
const router = useRouter();
const queryClient = useQueryClient();
return useMutation({
mutationFn: patchGroup,
onSuccess: () => {
success("팀 이름을 성공적으로 변경하였습니다.");
queryClient.invalidateQueries({ queryKey: ["groups"] });
queryClient.invalidateQueries({ queryKey: ["user"] });
router.back();
},
onError: () => {
error("팀 이름을 변경하지 못했습니다.");
Expand Down
1 change: 1 addition & 0 deletions src/api/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ export { default as useDeleteArticleComment } from "./articleComment/useDeleteAr
export { default as usePatchArticleComment } from "./articleComment/usePatchArticleComment";
export { default as useGetInvitation } from "./group/useGetInvitation";
export { default as useDeleteMember } from "./group/useDeleteMember";
export { default as usePatchArticle } from "./article/usePatchArticle";
6 changes: 6 additions & 0 deletions src/app/(route)/dashboard/[id]/_components/ArticleDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import ArticleBody from "./_internal/ArticleBody";
import ArticleComments from "./_internal/ArticleComments";
import { LinkButton } from "@/common";

const ArticleDetail = () => {
return (
<section className="bg-background-primary max-w-[900px] rounded-[20px] py-14 px-10">
<ArticleBody />
<ArticleComments />
<span className="flex-center">
<LinkButton className="w-full tablet:w-[250px]" href="/dashboard">
목록으로
</LinkButton>
</span>
</section>
);
};
Expand Down
154 changes: 154 additions & 0 deletions src/app/(route)/dashboard/[id]/_components/Modal/ArticleEditModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import { usePatchArticle } from "@/api/hooks";
import { BaseButton, Input, InputBox, Modal, Icon, FloatingButton } from "@/common";
import { ArticleDetail } from "@/types/ArticleType";
import { ChangeEvent, useState } from "react";
import Image from "next/image";
import useImageUpload from "@/hooks/useImageUpload";

interface ArticleEditModalProps {
isOpen: boolean;
onClose: () => void;
article: ArticleDetail;
}

interface FormStateType {
title: string;
content: string;
image: string | null;
}

const ArticleEditModal = ({ isOpen, onClose, article }: ArticleEditModalProps) => {
const { mutate: patchArticle, isPending } = usePatchArticle();

const [formState, setFormState] = useState<FormStateType>({
title: article.title,
content: article.content,
image: article.image,
});

const { preview, file, handleImageChange, uploadImage, isUploading, clear } = useImageUpload(
article.image ?? undefined,
);

const isDisabledEditButton = isPending || isUploading || !formState.title.trim() || !formState.content.trim();

const handleTextChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormState((prev) => ({ ...prev, [name]: value }));
};

const handleRemoveImage = () => {
clear();
setFormState((prev) => ({
...prev,
image: null,
}));
};

const handleEditClick = async () => {
const { title, content } = formState;

let imageUrl: string | null = null;

if (file) {
imageUrl = await uploadImage();
}
const newForm = {
title,
content,
image: imageUrl !== null ? imageUrl : formState.image === null ? null : formState.image,
};
patchArticle(
{
articleId: article.id,
body: newForm,
},
{
onSuccess: () => {
onClose();
},
},
);
};

return (
<Modal isOpen={isOpen} onClose={onClose}>
<Modal.Body className="flex-col-center gap-4">
<h3 className="text-text-primary text-lg-bold">게시글 수정하기</h3>

<form className="w-full flex flex-col gap-4">
<label htmlFor="articleTitle" className="text-text-primary text-lg-bold">
제목
</label>
<Input id="articleTitle" name="title" value={formState.title} onChange={handleTextChange} />

<InputBox
name="content"
label="내용"
value={formState.content}
onChange={handleTextChange}
textareaClassName="h-[300px]"
/>

<div className="flex flex-col gap-2">
<label className="text-text-primary text-lg-bold">이미지</label>
<input
id="articleImgEdit"
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) handleImageChange(selectedFile);
}}
/>

<div className="relative size-[120px]">
<label
htmlFor="articleImgEdit"
className="w-full h-full rounded-xl border border-border-primary flex-center cursor-pointer overflow-hidden hover:border-state-400 transition relative"
>
{preview ? (
<Image src={preview} alt="미리보기" fill className="object-cover" />
) : (
<span className="flex flex-col items-center gap-2 text-text-300 text-sm">
<Icon name="imgUpload" />
</span>
)}
</label>

{preview && (
<FloatingButton
iconName="x"
type="button"
onClick={handleRemoveImage}
className="absolute -top-2 -right-2 w-6 h-6 rounded-full flex-center"
/>
)}
</div>
</div>
</form>
</Modal.Body>

<Modal.Footer>
<BaseButton onClick={onClose} variant="outlinedSecondary" size="large">
닫기
</BaseButton>

<BaseButton
type="button"
onClick={handleEditClick}
variant="solid"
size="large"
disabled={isDisabledEditButton}
>
{isPending || isUploading ? "수정 중..." : "수정하기"}
</BaseButton>
</Modal.Footer>
</Modal>
);
};

export default ArticleEditModal;
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import ArticleTitle from "../../../_components/Article/_internal/ArticleTitle";
import ArticleWriter from "../../../_components/Article/_internal/ArticleWriter";
import ArticleContent from "../../../_components/Article/_internal/ArticleContent";
import ArticleLikeButton from "./ArticleLikeButton";
import ArticleEditModal from "./ArticleEditModal";
import ArticleEditModal from "../Modal/ArticleEditModal";
import { useState } from "react";

const ArticleBody = () => {
Expand Down Expand Up @@ -46,12 +46,19 @@ const ArticleBody = () => {
</div>

<div className="pt-6">
<ArticleContent content={article.content} />
<ArticleContent content={article.content} image={article.image} imgSize={200} />
</div>

<ArticleLikeButton />

<ArticleEditModal isOpen={isOpenEditModal} onClose={() => setIsOpenEditModal(false)} />
{isOpenEditModal && (
<ArticleEditModal
key={article.id}
isOpen={isOpenEditModal}
onClose={() => setIsOpenEditModal(false)}
article={article}
/>
)}
</section>
);
};
Expand Down

This file was deleted.

6 changes: 6 additions & 0 deletions src/app/(route)/dashboard/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { PageLayout } from "@/common";
import { ArticleDetail } from "./_components";
import { Metadata } from "next";

export const metadata: Metadata = {
title: "Coworkers | 자유게시판",
description: "게시글을 통해 팀원들과 소통해보세요.",
};

const DashBoardDetail = () => {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Link from "next/link";
import Image from "next/image";
import { useGetArticle } from "@/api/hooks";
import { Icon } from "@/common";
import ArticleBestBadge from "./_internal/ArticleBestBadge";
Expand All @@ -17,14 +16,12 @@ const BestArticleCard = ({ articleId }: { articleId: number }) => {

return (
<Link href={`/dashboard/${articleId}`} className="block">
<article className="flex flex-col gap-3 max-w-[350px] pc:gap-4 rounded-[20px] border bg-background-primary px-5 py-6">
<article className="h-full flex flex-col gap-3 pc:gap-4 rounded-[20px] border bg-background-primary px-5 py-6">
<ArticleBestBadge />
<div className="flex justify-between gap-4">
<div className="flex flex-col gap-3">
<ArticleTitle title={article?.title} />
<ArticleContent content={article?.content} />
</div>
{/* {article.image && <Image src={article.image} alt="게시글 이미지" width={60} height={60} />} */}

<div className="flex flex-col gap-4">
<ArticleTitle title={article?.title} />
<ArticleContent content={article?.content} image={article.image} imgSize={60} layout="row" />
</div>

<footer className="flex justify-between items-center">
Expand Down
Loading
Loading