feat: post 폴더 추가(cs)

This commit is contained in:
2025-05-13 17:37:39 +09:00
parent 2352a395fb
commit d1400a6908
65 changed files with 3010 additions and 8 deletions
@@ -1,5 +1,5 @@
import { useContext } from "react";
import { PromptContext } from "../providers/prompy-provider";
import { PromptContext } from "../providers/prompt-provider";
export const usePrompt = () => {
const { message, resolve, setMessage, setResolve } =
@@ -1,2 +1,2 @@
export { StoreProvider } from "./store-provider";
export { PromptProvider } from "./prompy-provider";
export { PromptProvider } from "./prompt-provider";
@@ -0,0 +1,10 @@
import { ModifyPage } from "@/pages/(post)/image";
export default async function PstBbsImagePostModifyPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <ModifyPage bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { ImagePost } from "@/pages/(post)/image";
export default async function PstBbsImagePostPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <ImagePost bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { ImagePage } from "@/pages/(post)/image";
export default async function PstBbsImagePage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <ImagePage bbsId={bbsId} />;
}
@@ -0,0 +1,10 @@
import { CreatePage } from "@/pages/(post)/image";
export default async function PstBbsImageRegisterPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <CreatePage bbsId={bbsId} />;
}
@@ -0,0 +1,10 @@
import { CreatePage } from "@/pages/(post)/normal";
export default async function PstBbsNormalAnswerPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <CreatePage bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { ModifyPage } from "@/pages/(post)/normal";
export default async function PstBbsNormalPostModifyPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <ModifyPage bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { NormalPost } from "@/pages/(post)/normal";
export default async function PstBbsNormalPostPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <NormalPost bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { NormalPage } from "@/pages/(post)/normal";
export default async function PstBbsNormalPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <NormalPage bbsId={bbsId} />;
}
@@ -0,0 +1,10 @@
import { CreatePage } from "@/pages/(post)/normal";
export default async function PstBbsNormalRegisterPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <CreatePage bbsId={bbsId} />;
}
@@ -0,0 +1,10 @@
import { ModifyPage } from "@/pages/(post)/notice";
export default async function PstBbsNoticePostModifyPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <ModifyPage bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { NoticePost } from "@/pages/(post)/notice";
export default async function PstBbsNoticePostPage({
params,
}: {
params: Promise<{ bbsId: string; pstNo: string }>;
}) {
const { bbsId, pstNo } = await params;
return <NoticePost bbsId={bbsId} pstNo={pstNo} />;
}
@@ -0,0 +1,10 @@
import { NoticePage } from "@/pages/(post)/notice";
export default async function PstBbsNoticePage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <NoticePage bbsId={bbsId} />;
}
@@ -0,0 +1,10 @@
import { CreatePage } from "@/pages/(post)/notice";
export default async function PstBbsNoticeRegisterPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <CreatePage bbsId={bbsId} />;
}
+1
View File
@@ -15,6 +15,7 @@
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@tanstack/react-query": "^5.67.1",
@@ -1,3 +1,4 @@
import type { AxiosResponse } from "axios";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
@@ -42,6 +43,10 @@ export class AttachmentApiService implements AttachmentService {
return URL.createObjectURL(blob);
}
public async deleteAttachmentByAtflId(atflId: string): Promise<void> {
await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`);
}
public async getImageUrlByAtflCd(atflCd: string): Promise<string> {
if (!atflCd) return "";
const response = await fetch(
@@ -50,6 +55,16 @@ export class AttachmentApiService implements AttachmentService {
const blob = await response.blob();
return URL.createObjectURL(blob);
}
public async downloadAttachmentByAtflCd(
atflCd: string,
): Promise<AxiosResponse> {
const response = await client.get(
`${ENDPOINT.ATTACH_SERVICE.DOWNLOAD}/${atflCd}`,
{ responseType: "blob" },
);
return response;
}
}
export const AttachmentApi = new AttachmentApiService();
@@ -1,3 +1,4 @@
import type { AxiosResponse } from "axios";
import type { PageableList, PageableParams } from "@/shared/model";
export interface AttachmentParams extends PageableParams {
@@ -36,7 +37,9 @@ export interface AttachmentService {
getAttachmentsList: (params: AttachmentParams) => Promise<AttachmentList>;
getAttachmentsInfo: (atflCd: string) => Promise<Attachment[]>;
deleteAttachment: (atflId: string) => Promise<void>;
deleteAttachmentByAtflId: (atflId: string) => Promise<void>;
deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>;
getImageUrl: (atflId: string) => Promise<string>;
getImageUrlByAtflCd: (atflCd: string) => Promise<string>;
downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>;
}
+3
View File
@@ -1,9 +1,12 @@
export { BoardApi } from "./api/board-service";
export {
boardFormSchema,
BOARD_POSTING_CODE,
type Board,
type BoardType,
type BoardFormData,
type BoardParams,
type BoardPopup,
type BoardPostingCode,
} from "./model/board-interface";
export { BoardTemplateApi } from "./api/board-template-service";
@@ -8,7 +8,15 @@ export const BOARD_TYPE = {
BBSMOVIE: "BBSMOVIE",
} as const;
export const BOARD_POSTING_CODE = {
팝업게시: "01",
상단게시: "02",
기간게시: "03",
} as const;
export type BoardType = (typeof BOARD_TYPE)[keyof typeof BOARD_TYPE];
export type BoardPostingCode =
(typeof BOARD_POSTING_CODE)[keyof typeof BOARD_POSTING_CODE];
export interface BoardParams extends PageableParams {
keywordType?: string;
+8 -2
View File
@@ -11,9 +11,15 @@ import { CodeApi } from "../api/code-service";
type CodeSelectProps = {
upCd: string;
placeholder?: string;
} & React.ComponentPropsWithoutRef<typeof Select>;
function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) {
function CodeSelect({
upCd,
defaultValue,
placeholder = "선택",
...props
}: CodeSelectProps) {
const { data: code } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd],
queryFn: () => CodeApi.getCode(upCd),
@@ -21,7 +27,7 @@ function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) {
});
const defaultValueText =
code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? "선택";
code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? placeholder;
return (
<Select {...props}>
@@ -0,0 +1,60 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
commentFormSchema,
commentUpdateFormSchema,
type Comment,
type CommentService,
type CommentList,
type CommentParams,
type CommentFormData,
type CommentUpdateFormData,
} from "../model/comment-interface";
export class CommentApiService implements CommentService {
public async getComments(params: CommentParams): Promise<CommentList> {
const response = await client.get<CommentList>(
`${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`,
{ params },
);
return response.data;
}
public async createComment(data: CommentFormData): Promise<Comment> {
const validatedData = commentFormSchema.parse(data);
const response = await client.post<Comment>(
`${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`,
validatedData,
);
return response.data;
}
public async updateComment(
bbsId: number,
pstNo: number,
cmntNo: number,
data: CommentUpdateFormData,
): Promise<Comment> {
const validatedData = commentUpdateFormSchema.parse(data);
const response = await client.put<Comment>(
`${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`,
validatedData,
);
return response.data;
}
public async deleteComment(
bbsId: number,
pstNo: number,
cmntNo: number,
cmntPswd: string,
): Promise<Comment> {
const response = await client.delete<Comment>(
`${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`,
{ params: { cmntPswd } },
);
return response.data;
}
}
export const CommentApi = new CommentApiService();
+10
View File
@@ -0,0 +1,10 @@
export { CommentApi } from "./api/comment-service";
export {
commentFormSchema,
commentUpdateFormSchema,
type Comment,
type CommentList,
type CommentParams,
type CommentFormData,
type CommentUpdateFormData,
} from "./model/comment-interface";
@@ -0,0 +1,55 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface CommentParams extends PageableParams {
keywordType?: string;
keyword?: string;
pstNo: number;
delYn?: string;
}
export interface Comment {
cmntEvl: number;
pstNo: number;
cmntNo: number;
cmntCn: string;
wrtrNm: string;
regDt: string;
rgtr: string;
mdfcnDt: string;
mdfr: string;
delYn: string;
}
export const commentFormSchema = z.object({
pstNo: z.number(),
cmntCn: z.string(),
wrtrNm: z.string().nullish(),
cmntPswd: z.string(),
cmntEvl: z.number().nullish(),
});
export const commentUpdateFormSchema = commentFormSchema.omit({
pstNo: true,
});
export type CommentList = PageableList<Comment>;
export type CommentFormData = z.infer<typeof commentFormSchema>;
export type CommentUpdateFormData = z.infer<typeof commentUpdateFormSchema>;
export interface CommentService {
getComments(params: CommentParams): Promise<CommentList>;
createComment(data: CommentFormData): Promise<Comment>;
updateComment(
bbsId: number,
pstNo: number,
cmntNo: number,
data: CommentUpdateFormData,
): Promise<Comment>;
deleteComment(
bbsId: number,
pstNo: number,
cmntNo: number,
cmntPswd: string,
): Promise<Comment>;
}
@@ -0,0 +1,68 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
postFormSchema,
postUpdateFormSchema,
type Post,
type PostInfo,
type PostList,
type PostParams,
type PostService,
type PostFormData,
type PostUpdateFormData,
} from "../model/post-interface";
export class PostApiService implements PostService {
public async getPostList(
bbsId: string,
params: PostParams,
): Promise<PostList> {
const response = await client.get<PostList>(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}`,
{ params },
);
return response.data;
}
public async getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo> {
const response = await client.get<PostInfo>(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`,
);
return response.data;
}
public async createPost(data: PostFormData): Promise<Post> {
const validatedData = postFormSchema.parse(data);
const response = await client.post<Post>(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}`,
validatedData,
);
return response.data;
}
public async updatePost(
bbsId: string,
pstNo: string,
data: PostUpdateFormData,
): Promise<Post> {
const validatedData = postUpdateFormSchema.parse(data);
const response = await client.put<Post>(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`,
validatedData,
);
return response.data;
}
public async deletePost(
bbsId: string,
pstNo: string,
pstPswd?: string,
): Promise<void> {
await client.delete(
`${ENDPOINT.BOARD_SERVICE.POSTS_USER}/${bbsId}/${pstNo}`,
{ params: { pstPswd } },
);
}
}
export const PostApi = new PostApiService();
+11
View File
@@ -0,0 +1,11 @@
export { PostApi } from "./api/post-service";
export {
postFormSchema,
postUpdateFormSchema,
type Post,
type PostList,
type PostParams,
type PostService,
type PostFormData,
type PostUpdateFormData,
} from "./model/post-interface";
@@ -0,0 +1,85 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface PostParams extends PageableParams {
keywordType?: string;
keyword?: string;
popupYn?: string;
upndFixYn?: string;
prdPstgYn?: string;
siteId?: string;
}
export interface Post {
bbsId: number;
rowNo: number;
pstNo: number;
pstTtl: string;
inqNocs: number;
rfrncUrl: string;
popupYn: string;
upndFixYn: string;
prdPstgYn: string;
rootPath: string;
pstPath: string;
ansPstn: number;
wrtrNm: string;
wrtrCplc: string;
atflCd: string;
regDt: string;
rgtrNm: string;
cmntCnt: number;
}
export interface PostInfo extends Post {
pstCn: string;
rgtr: string;
mdfcnDt: string;
mdfr: string;
}
const postEtcSchema = z.object({
bbsPstgTypeCd: z.string(),
pstgPrdSeCd: z.string(),
pstgBgngDt: z.string().nullish(),
pstgEndDt: z.string().nullish(),
});
export const postFormSchema = z.object({
bbsId: z.number(),
pstTtl: z.string().nonempty(),
pstCn: z.string().nullish(),
rfrncUrl: z.string().nullish(),
popupYn: z.string().nullish(),
popupYnEtc: z.array(postEtcSchema).nullish(),
upndFixYn: z.string().nullish(),
upndFixYnEtc: z.array(postEtcSchema).nullish(),
prdPstgYn: z.string().nullish(),
prdPstgYnEtc: z.array(postEtcSchema).nullish(),
pstPswd: z.string(),
wrtrNm: z.string().nullish(),
wrtrCplc: z.string().nullish(),
atflCd: z.string().nullish(),
parentPstNo: z.number().nullish(),
});
export const postUpdateFormSchema = postFormSchema.omit({
bbsId: true,
parentPstNo: true,
});
export type PostList = PageableList<Post>;
export type PostFormData = z.infer<typeof postFormSchema>;
export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>;
export interface PostService {
getPostList(bbsId: string, params: PostParams): Promise<PostList>;
getPostInfo(bbsId: string, pstNo: string): Promise<PostInfo>;
createPost(data: PostFormData): Promise<Post>;
updatePost(
bbsId: string,
pstNo: string,
data: PostUpdateFormData,
): Promise<Post>;
deletePost(bbsId: string, pstNo: string, pstPswd?: string): Promise<void>;
}
+1
View File
@@ -0,0 +1 @@
export { handleTusUpload } from "./lib/utils";
+60
View File
@@ -0,0 +1,60 @@
import { Upload, type HttpResponse } from "tus-js-client";
import { getCookie, setCookie } from "@/shared/lib";
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
type UploadOptions = {
handleUploadStart?: (upload: Upload) => void;
handleProgress?: (percentage: string) => void;
handleSuccess?: (upload: Upload) => void;
handleResponse?: (res: HttpResponse, upload: Upload) => void;
};
export const handleTusUpload = (file: File, options?: UploadOptions) => {
let metadata = {};
if (!file) return;
metadata = {
filename: file.name,
filetype: file.type,
};
const atflCd = getCookie("atfl-cd-key");
if (atflCd) {
metadata = {
...metadata,
atflCd,
};
}
const upload = new Upload(file, {
endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
chunkSize: 10 * 1024 * 1024,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
metadata,
onError(error) {
console.error(`Failed because: ${error}`);
},
onProgress(bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
options?.handleProgress?.(percentage);
},
onAfterResponse(req, res) {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? "");
options?.handleResponse?.(res, upload);
},
onSuccess() {
options?.handleSuccess?.(upload);
},
});
upload.start();
options?.handleUploadStart?.(upload);
console.log("upload start >>>>>", upload);
};
+4
View File
@@ -0,0 +1,4 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { ImagePage } from "./ui/image-page";
export { ImagePost } from "./ui/image-post";
@@ -0,0 +1,43 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import { PostApi, postFormSchema, type PostFormData } from "@/entities/post";
import { useAlertStore, useUserStore } from "@/shared/lib";
function CreatePage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const { userInfo } = useUserStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createPost } = useMutation({
mutationFn: (data: PostFormData) => PostApi.createPost(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<PostFormData>({
resolver: zodResolver(postFormSchema),
values: {
bbsId: parseInt(bbsId),
pstTtl: "",
wrtrNm: userInfo?.userName ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 게시글 등록</h3>
</div>
<PostForm form={form} handleSave={createPost} />
</div>
);
}
export { CreatePage };
@@ -0,0 +1,3 @@
"use client";
export { PostPage as ImagePage } from "@/widgets/post";
@@ -0,0 +1,3 @@
"use client";
export { Post as ImagePost } from "@/widgets/post";
@@ -0,0 +1,53 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import {
PostApi,
postUpdateFormSchema,
type PostUpdateFormData,
} from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
});
const form = useForm<PostUpdateFormData>({
resolver: zodResolver(postUpdateFormSchema),
values: {
...postInfo,
pstTtl: postInfo?.pstTtl ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">이미지 게시글 수정</h3>
</div>
<PostForm form={form} handleSave={updatePost} />
</div>
);
}
export { ModifyPage };
+4
View File
@@ -0,0 +1,4 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { NormalPage } from "./ui/normal-page";
export { NormalPost } from "./ui/normal-post";
@@ -0,0 +1,51 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import { PostApi, postFormSchema, type PostFormData } from "@/entities/post";
import { useAlertStore, useUserStore } from "@/shared/lib";
function CreatePage({ bbsId, pstNo }: { bbsId: string; pstNo?: string }) {
const answerRegisterMode = !!pstNo;
const router = useRouter();
const { userInfo } = useUserStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createPost } = useMutation({
mutationFn: (data: PostFormData) => PostApi.createPost(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<PostFormData>({
resolver: zodResolver(postFormSchema),
values: {
bbsId: parseInt(bbsId),
pstTtl: "",
wrtrNm: userInfo?.userName ?? "",
pstPswd: userInfo?.userUnqId ?? "",
popupYn: "N",
upndFixYn: "N",
prdPstgYn: "N",
parentPstNo: pstNo ? parseInt(pstNo) : null,
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{answerRegisterMode ? "답글 등록" : "게시글 등록"}
</h3>
</div>
<PostForm form={form} handleSave={createPost} />
</div>
);
}
export { CreatePage };
@@ -0,0 +1,53 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import {
PostApi,
postUpdateFormSchema,
type PostUpdateFormData,
} from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
});
const form = useForm<PostUpdateFormData>({
resolver: zodResolver(postUpdateFormSchema),
values: {
...postInfo,
pstTtl: postInfo?.pstTtl ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">게시글 수정</h3>
</div>
<PostForm form={form} handleSave={updatePost} />
</div>
);
}
export { ModifyPage };
@@ -0,0 +1,3 @@
"use client";
export { PostPage as NormalPage } from "@/widgets/post";
@@ -0,0 +1,3 @@
"use client";
export { Post as NormalPost } from "@/widgets/post";
+4
View File
@@ -0,0 +1,4 @@
export { CreatePage } from "./ui/create-page";
export { ModifyPage } from "./ui/modify-page";
export { NoticePage } from "./ui/notice-page";
export { NoticePost } from "./ui/notice-post";
@@ -0,0 +1,90 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge, Button } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import type { Post } from "@/entities/post";
export const columns: ColumnDef<Post>[] = [
{
accessorKey: "rowNo",
header: "NO",
},
{
accessorKey: "pstTtl",
header: "제목",
cell: ({ row }) => {
const { pstTtl, inqNocs } = row.original;
return (
<div className="flex min-w-[20vw] items-center gap-2 text-left">
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
{pstTtl}
</div>
);
},
},
{
accessorKey: "inqNocs",
header: "조회수",
},
{
accessorKey: "atflCd",
header: "첨부파일",
cell: ({ row }) => {
const atflCd = row.original.atflCd;
if (!atflCd) return null;
return <AttachmentButton atflCd={atflCd} />;
},
},
{
accessorKey: "rgtrNm",
header: "작성자",
},
{
accessorKey: "regDt",
header: "등록일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
},
];
const AttachmentButton = ({ atflCd }: { atflCd: string }) => {
const handleDownload = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
try {
let filename = "";
const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd);
const contentDisposition = response.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = contentDisposition.match(
/filename\*=UTF-8''([^;]+)/,
);
if (filenameMatch) {
filename = decodeURIComponent(filenameMatch[1]);
}
}
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
} catch (error) {
console.error(error);
}
};
return (
<Button size="icon" color="white" onClick={handleDownload}>
<i className="diveicon di-download" />
</Button>
);
};
@@ -0,0 +1,46 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import { PostApi, postFormSchema, type PostFormData } from "@/entities/post";
import { useAlertStore, useUserStore } from "@/shared/lib";
function CreatePage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const { userInfo } = useUserStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: createPost } = useMutation({
mutationFn: (data: PostFormData) => PostApi.createPost(data),
onSuccess: () => {
alert("등록되었습니다.", router.back);
},
});
const form = useForm<PostFormData>({
resolver: zodResolver(postFormSchema),
values: {
bbsId: parseInt(bbsId),
pstTtl: "",
wrtrNm: userInfo?.userName ?? "",
pstPswd: userInfo?.userUnqId ?? "",
popupYn: "N",
upndFixYn: "N",
prdPstgYn: "N",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">공지 게시글 등록</h3>
</div>
<PostForm form={form} handleSave={createPost} />
</div>
);
}
export { CreatePage };
@@ -0,0 +1,53 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import {
PostApi,
postUpdateFormSchema,
type PostUpdateFormData,
} from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const { data: postInfo } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
});
const { mutateAsync: updatePost } = useMutation({
mutationFn: (data: PostUpdateFormData) =>
PostApi.updatePost(bbsId, pstNo, data),
onSuccess: () => {
alert("수정되었습니다.", router.back);
},
});
const form = useForm<PostUpdateFormData>({
resolver: zodResolver(postUpdateFormSchema),
values: {
...postInfo,
pstTtl: postInfo?.pstTtl ?? "",
pstPswd: userInfo?.userUnqId ?? "",
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">공지 게시글 수정</h3>
</div>
<PostForm form={form} handleSave={updatePost} />
</div>
);
}
export { ModifyPage };
@@ -0,0 +1,3 @@
"use client";
export { PostPage as NoticePage } from "@/widgets/post";
@@ -0,0 +1,3 @@
"use client";
export { Post as NoticePost } from "@/widgets/post";
+3
View File
@@ -8,6 +8,7 @@ export const ENDPOINT = {
EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor",
IMAGES: "/attach-service/api/v1/images",
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
DOWNLOAD: "/attach-service/api/v1/atflcd/download",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
@@ -37,6 +38,8 @@ export const ENDPOINT = {
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
POSTS_USER: "/board-service/api/v1/posts/user",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
},
};
+2
View File
@@ -1,3 +1,5 @@
export { getCookie, setCookie, removeCookie } from "./cookie";
export { useAlertStore } from "./use-alert-store";
export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
export { usePrompt } from "./use-prompt";
+26
View File
@@ -0,0 +1,26 @@
import { useContext } from "react";
import { PromptContext } from "../providers/prompt-provider";
export const usePrompt = () => {
const { message, resolve, setMessage, setResolve } =
useContext(PromptContext);
const prompt = (message: string) => {
setMessage(message);
return new Promise<string>((resolve) => {
setResolve(() => resolve);
});
};
const onSubmit = (value: string) => {
resolve?.(value);
setMessage("");
};
const onCancel = () => {
resolve?.("");
setMessage("");
};
return { message, isOpen: !!message, prompt, onSubmit, onCancel };
};
@@ -0,0 +1,28 @@
import { createContext, useState } from "react";
interface PromptContext {
message?: string;
resolve?: (value: string) => void;
setMessage: (message: string) => void;
setResolve: (resolve: (value: string) => void) => void;
}
export const PromptContext = createContext<PromptContext>({
message: "",
resolve() {},
setMessage() {},
setResolve() {},
});
export const PromptProvider = ({ children }: { children: React.ReactNode }) => {
const [message, setMessage] = useState<string>();
const [resolve, setResolve] = useState<(value: string) => void>();
return (
<PromptContext.Provider
value={{ message, resolve, setMessage, setResolve }}
>
{children}
</PromptContext.Provider>
);
};
+74 -4
View File
@@ -1,23 +1,93 @@
import { createStore } from "zustand/vanilla";
import type { MenuItem } from "@/entities/menu";
export const BBS_TYPE = {
BBSNOTICE: "BBSNOTICE",
BBSNORMAL: "BBSNORMAL",
BBSFAQ: "BBSFAQ",
BBSIMAGE: "BBSIMAGE",
BBSMOVIE: "BBSMOVIE",
} as const;
export type BbsType = (typeof BBS_TYPE)[keyof typeof BBS_TYPE];
export interface IBbs {
bbsId: number;
bbsTypeCd: BbsType;
bbsTypeNm: string;
bbsNm: string;
bbsExpln: string;
rfrncUseYn: string;
atflUseYn: string;
atflMaxCnt: number;
atflMaxFileSz: number;
ansUseYn: string;
ansUseAuthrt: string;
cmntUseYn: string;
dgstfnEvlUseYn: string;
smsUseYn: string;
upndFixUseYn: string;
pstgPrdUseYn: string;
popupUseYn: string;
delYn: string;
useYn: string;
mdfcnDt: string;
mdfrNm: string;
}
interface IMenu {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number | null;
sortSeq: number;
iconNm?: string;
children: IMenu[] | null;
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
menuPath: string;
qrcd: string;
useYn: string;
otptYn: string;
newWndwYn: string;
requiredUrlPath: boolean;
}
interface IAuthority {
mngAuthrtYn: string;
inqAuthrtYn: string;
regAuthrtYn: string;
mdfcnAuthrtYn: string;
delAuthrtYn: string;
}
export type MenuState = {
currentMenuInfo: MenuItem | null;
bbsInfo: IBbs | null;
currentMenuInfo: IMenu | null;
pageMenuAuthority: IAuthority | null;
};
export type MenuActions = {
setCurrentMenuInfo: (menu: MenuItem) => void;
setBbsInfo: (bbs: IBbs) => void;
setCurrentMenuInfo: (menu: IMenu) => void;
setPageMenuAuthority: (authority: IAuthority) => void;
};
export type MenuStore = MenuState & MenuActions;
export const defaultInitState: MenuState = {
currentMenuInfo: null,
bbsInfo: null,
pageMenuAuthority: null,
};
export const createMenuStore = (initState: MenuState = defaultInitState) => {
return createStore<MenuStore>()((set) => ({
...initState,
setCurrentMenuInfo: (menu: MenuItem) => set({ currentMenuInfo: menu }),
setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }),
setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }),
setPageMenuAuthority: (authority: IAuthority) =>
set({ pageMenuAuthority: authority }),
}));
};
+1
View File
@@ -5,6 +5,7 @@ export interface IUserInfo {
mdfcnDt: string;
rgtr: string;
mdfr: string;
adminRoleYn: string;
userUnqId: string;
userId: string;
userName: string;
@@ -0,0 +1,158 @@
import type { HTMLAttributes } from "react";
import {
flexRender,
type RowData,
type Table as TableInstance,
} from "@tanstack/react-table";
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from "@radix-ui/react-icons";
import { Button, Checkbox, Label, cn } from "@common/components";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
align?: "left" | "center" | "right";
rowSpan?: number;
}
}
type GridViewDataTableProps<T> = {
table: TableInstance<T>;
variant?: "default" | "secondary" | "line";
className?: HTMLAttributes<HTMLTableElement>["className"];
viewPagination?: boolean;
onRowClick?: (row: T) => void;
};
function GridViewDataTable<T>({
table,
className,
viewPagination,
onRowClick,
}: GridViewDataTableProps<T>) {
return (
<>
<div className="mb-4 flex items-center gap-2 rounded-sm border border-border p-4">
<Checkbox
id="all-select"
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
/>
<Label htmlFor="all-select" className="text-2xl">
전체선택
</Label>
</div>
<div className={cn("w-full pb-4", className)}>
<div className="grid grid-cols-5 gap-6">
{table.getRowModel().rows.map((row) => (
<div
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={cn(
"rounded-md border border-border p-4 shadow-md",
onRowClick && "cursor-pointer hover:bg-accent",
)}
>
{row.getVisibleCells().map((cell) => {
const { meta } = cell.column.columnDef;
return (
<div key={cell.id} {...meta}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
</div>
))}
</div>
</div>
{viewPagination && <Pagination table={table} />}
</>
);
}
function Pagination<T>({ table }: { table: TableInstance<T> }) {
const pageCount = table.getPageCount();
const currentPage = table.getState().pagination.pageIndex;
const maxVisiblePages = 10; // 한 번에 보여줄 최대 페이지 수
let startPage = Math.max(0, currentPage - maxVisiblePages / 2); // 시작 페이지
const endPage = Math.min(pageCount, startPage + maxVisiblePages); // 끝 페이지
if (endPage - startPage < maxVisiblePages) {
startPage = Math.max(0, endPage - maxVisiblePages);
}
const pageNumbers = Array(endPage - startPage)
.fill(0)
.map((_, i) => startPage + i);
return (
<div className="mt-[24px] flex items-center justify-center space-x-2">
<Button
variant="ghost"
color="white"
className="hidden size-10 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">첫 페이지로</span>
<DoubleArrowLeftIcon className="size-4" />
</Button>
<Button
variant="ghost"
color="white"
className="size-10 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">이전 페이지로</span>
<ChevronLeftIcon className="size-4" />
</Button>
<div className="flex items-center justify-center text-sm font-medium">
{pageNumbers.map((pageNumber) => (
<Button
key={pageNumber}
variant="ghost"
className={cn(
"inline-block size-10 !bg-transparent p-0 text-foreground hover:!bg-accent",
currentPage === pageNumber &&
"!bg-primary !font-bold !text-primary-foreground hover:!bg-primary",
)}
onClick={() => table.setPageIndex(pageNumber)}
>
{pageNumber + 1}
</Button>
))}
</div>
<Button
variant="ghost"
color="white"
className="size-10 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">다음 페이지로</span>
<ChevronRightIcon className="size-4" />
</Button>
<Button
variant="ghost"
color="white"
className="hidden size-10 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">마지막 페이지로</span>
<DoubleArrowRightIcon className="size-4" />
</Button>
</div>
);
}
export { GridViewDataTable };
+3
View File
@@ -0,0 +1,3 @@
export { Post } from "./ui/post";
export { PostForm } from "./ui/post-form";
export { PostPage } from "./ui/post-page";
@@ -0,0 +1,123 @@
import dayjs from "dayjs";
import Image from "next/image";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge, Checkbox, Skeleton } from "@common/components";
import type { Post } from "@/entities/post";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { AttachmentApi } from "@/entities/attachment";
export const createColumns = (
thumbRefs: React.MutableRefObject<Map<number, string>>,
): ColumnDef<Post>[] => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => {
const { pstTtl, inqNocs } = row.original;
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
</div>
<span className="text-3xl font-bold leading-none text-label">
{pstTtl}
</span>
</div>
);
},
},
{
id: "atflCd",
cell: ({ row }) => {
const atflCd = row.original.atflCd;
return (
<ThumbImage
atflCd={atflCd}
pstNo={row.original.pstNo}
thumbRefs={thumbRefs}
/>
);
},
},
{
id: "rgtrNm",
cell: ({ row }) => {
const { rgtrNm } = row.original;
return (
<span className="text-[1.4rem] font-bold text-label">{rgtrNm}</span>
);
},
},
{
id: "regDt",
cell: ({ row }) => (
<span className="text-[1.2rem] text-label">
{dayjs(row.original.regDt).format("YYYY-MM-DD")}
</span>
),
},
];
type ThumbImageProps = {
atflCd: string;
pstNo: number;
thumbRefs: React.MutableRefObject<Map<number, string>>;
};
const ThumbImage = ({ atflCd, pstNo, thumbRefs }: ThumbImageProps) => {
const prevImage = thumbRefs.current.get(pstNo);
const { data: imageUrl, isLoading } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd],
queryFn: () => AttachmentApi.getImageUrlByAtflCd(atflCd),
select: (url) => {
if (url) thumbRefs.current.set(pstNo, url);
return url;
},
enabled: !prevImage && !!atflCd,
});
if (isLoading)
return (
<div className="my-6 w-full">
<Skeleton className="mx-auto h-[16rem] w-[16rem]" />
</div>
);
if (imageUrl)
return (
<div className="relative my-6 flex h-[16rem] w-full items-center justify-center">
<Image
src={imageUrl}
className="object-contain"
fill
alt="썸네일 이미지"
/>
</div>
);
return (
<div className="my-6 flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background">
<i className="diveicon di-image text-3xl" />
</div>
);
};
@@ -0,0 +1,141 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge, Button, Checkbox } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import type { Post } from "@/entities/post";
export const columns: ColumnDef<Post & { isUpndFixPost?: boolean }>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(!!checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
className="h-4 w-4 rounded border-gray-300"
/>
),
},
{
accessorKey: "rowNo",
header: "NO",
cell: ({ row }) => {
if (row.original.isUpndFixPost) {
return (
<Badge
color="orange"
className="flex size-10 items-center justify-center"
>
<i className="diveicon di-volume-up-fill" />
</Badge>
);
}
return row.original.rowNo;
},
},
{
accessorKey: "pstTtl",
header: "제목",
cell: ({ row }) => {
const { ansPstn, pstTtl, inqNocs, cmntCnt } = row.original;
return (
<div
className="flex min-w-[20vw] items-center gap-2 text-left"
style={{ paddingInlineStart: `${(ansPstn - 1) * 20}px` }}
>
{ansPstn > 0 && (
<div className="flex items-center gap-1">
<div className="-rotate-90">
<i className="diveicon di-arrow-90left-down rotate-90" />
</div>
<Badge color="smokyBlue">답변</Badge>
</div>
)}
{inqNocs >= 100 && <Badge color="red">HOT</Badge>}
{dayjs().diff(dayjs(row.original.regDt), "hour") < 24 && (
<Badge color="blue">NEW</Badge>
)}
{pstTtl}
{cmntCnt > 0 && (
<div className="ml-4 flex items-end gap-2">
<i className="diveicon di-chat-box-left-text text-gray-400" />
<span className="text-gray-400">{cmntCnt}</span>
</div>
)}
</div>
);
},
},
{
accessorKey: "inqNocs",
header: "조회수",
},
{
accessorKey: "atflCd",
header: "첨부파일",
cell: ({ row }) => {
const atflCd = row.original.atflCd;
if (!atflCd) return null;
return <AttachmentButton atflCd={atflCd} />;
},
},
{
accessorKey: "rgtrNm",
header: "작성자",
},
{
accessorKey: "regDt",
header: "등록일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
},
];
const AttachmentButton = ({ atflCd }: { atflCd: string }) => {
const handleDownload = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
try {
let filename = "";
const response = await AttachmentApi.downloadAttachmentByAtflCd(atflCd);
const contentDisposition = response.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = contentDisposition.match(
/filename\*=UTF-8''([^;]+)/,
);
if (filenameMatch) {
filename = decodeURIComponent(filenameMatch[1]);
}
}
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
} catch (error) {
console.error(error);
}
};
return (
<Button size="icon" color="white" onClick={handleDownload}>
<i className="diveicon di-download" />
</Button>
);
};
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import type { FieldPath, UseFormReturn } from "react-hook-form";
import type { HttpResponse, Upload } from "tus-js-client";
import { Button, FileUploader } from "@common/components";
import { handleTusUpload } from "@/entities/tus";
import { type Attachment } from "@/entities/attachment";
import type { PostFormData, PostUpdateFormData } from "@/entities/post";
import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib";
function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({
form,
attachmentData,
deleteAttachmentIds,
setDeleteAttachmentIds,
}: {
form: UseFormReturn<T>;
attachmentData?: Attachment[];
deleteAttachmentIds: string[];
setDeleteAttachmentIds: (deleteAttachmentIds: string[]) => void;
}) {
const { setMessage: alert } = useAlertStore((state) => state);
const { bbsInfo } = useMenuStore((state) => state);
const [attachments, setAttachments] = useState<Record<string, Attachment>>(
{},
);
const handleTusResponse = (res: HttpResponse, upload: Upload) => {
const fileInfo = res.getHeader("X-File-Info");
if (
typeof fileInfo !== "string" ||
fileInfo === undefined ||
fileInfo === null
)
return;
const { atflCd, atflId } = JSON.parse(fileInfo) ?? {};
if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd);
if (atflId) {
setAttachments((prev) => ({
...prev,
[atflId]: {
atflCd,
atflId,
orgnlFileNm: (upload.file as File).name,
atflFlieSz: (upload.file as File).size,
atflType: (upload.file as File).type,
},
}));
}
};
const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const atflCd = form.getValues("atflCd" as FieldPath<T>);
if (atflCd) setCookie("atfl-cd-key", atflCd as string);
const files = e.target.files;
if (!files) return;
if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) {
alert("첨부파일 최대 개수를 초과했습니다.");
return;
}
for (const file of files) {
const fileSize = Math.round(file.size / 1024); // Byte -> KB
if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) {
alert("첨부파일 최대 크기를 초과했습니다.");
return;
}
handleTusUpload(file, {
handleResponse: handleTusResponse,
});
}
};
useEffect(() => {
const mapper = (data: Attachment[]) =>
data.reduce(
(acc, curr) => {
acc[curr.atflId] = curr;
return acc;
},
{} as Record<string, Attachment>,
);
if (attachmentData) setAttachments(mapper(attachmentData));
}, [attachmentData]);
return (
<FileUploader
id="atflCd"
multiple
className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]"
onUpload={onUpload}
customPreview={
<div className="flex w-full gap-4">
{Object.entries(attachments).map(([atflId, attachment]) => {
if (deleteAttachmentIds.includes(atflId)) return null;
return (
<div
key={atflId}
className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground"
>
<span>{attachment.orgnlFileNm}</span>
<Button
color="lightGray"
className="flex size-7 items-center justify-center rounded-full p-0"
onClick={() =>
setDeleteAttachmentIds([...deleteAttachmentIds, atflId])
}
>
<i className="diveicon di-x text-xs" />
</Button>
</div>
);
})}
</div>
}
/>
);
}
export { GeneralFileUploader };
@@ -0,0 +1,75 @@
import { useRouter, usePathname } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
import { useMenuStore, useUserStore } from "@/shared/lib";
type PostActionProps = {
bbsId: string;
pstNo: string;
children: React.ReactNode;
handleDelete: () => Promise<void>;
};
function PostAction({ children, handleDelete }: PostActionProps) {
const router = useRouter();
const pathname = usePathname();
const { confirm } = useConfirm();
const { bbsInfo } = useMenuStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const onDelete = async () => {
if (await confirm("삭제하시겠습니까?")) handleDelete();
};
const checkAnsUseAuthrt = () => {
// 관리자
if (bbsInfo?.ansUseAuthrt === "A") {
return userInfo?.adminRoleYn === "Y";
}
// 회원
if (bbsInfo?.ansUseAuthrt === "M") {
return !!userInfo;
}
// 비회원
return true;
};
return (
<div>
{children}
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
size="lg"
color="white"
type="button"
onClick={() => router.back()}
>
목록
</Button>
<Button
size="lg"
type="button"
onClick={() => router.push(`${pathname}/modify`)}
>
수정
</Button>
{bbsInfo?.ansUseYn === "Y" && (
<Button
size="lg"
type="button"
color="gray"
onClick={() => router.push(`${pathname}/answer`)}
disabled={!checkAnsUseAuthrt()}
>
답글
</Button>
)}
<Button size="lg" color="red" type="button" onClick={onDelete}>
삭제
</Button>
</div>
</div>
);
}
export { PostAction };
@@ -0,0 +1,259 @@
import dayjs from "dayjs";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Input, Textarea } from "@common/components";
import {
CommentApi,
commentFormSchema,
type CommentFormData,
type CommentUpdateFormData,
} from "@/entities/comment";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, usePrompt, useUserStore } from "@/shared/lib";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) {
const { prompt } = usePrompt();
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const { userInfo } = useUserStore((state) => state);
const { setMessage: alert } = useAlertStore((state) => state);
const [editingCmntNo, setEditingCmntNo] = useState<number | null>(null);
const [editValue, setEditValue] = useState<string>("");
const { data: comments } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo],
queryFn: () => CommentApi.getComments({ pstNo }),
enabled: !!pstNo,
});
const { mutate: createComment } = useMutation({
mutationFn: (data: CommentFormData) => CommentApi.createComment(data),
onSuccess: () => {
alert("등록되었습니다.");
form.setValue("cmntCn", "");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
});
},
});
const { mutate: updateComment } = useMutation({
mutationFn: ({
cmntNo,
data,
}: {
cmntNo: number;
data: CommentUpdateFormData;
}) => CommentApi.updateComment(bbsId, pstNo, cmntNo, data),
onSuccess: () => {
alert("수정되었습니다.");
setEditingCmntNo(null);
setEditValue("");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
});
},
});
const { mutate: deleteComment } = useMutation({
mutationFn: ({ cmntNo, cmntPswd }: { cmntNo: number; cmntPswd: string }) =>
CommentApi.deleteComment(bbsId, pstNo, cmntNo, cmntPswd),
onSuccess: () => {
alert("삭제되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
});
},
});
const form = useForm<CommentFormData>({
resolver: zodResolver(commentFormSchema),
values: {
pstNo,
cmntCn: "",
wrtrNm: userInfo?.userName ?? "",
cmntPswd: userInfo?.userUnqId ?? "",
},
});
const onSubmit = (data: CommentFormData) => {
createComment(data);
};
const getCommentPassword = async () => {
let cmntPswd = "";
if (userInfo?.userUnqId) {
cmntPswd = userInfo?.userUnqId;
} else {
cmntPswd = await prompt("비밀번호를 입력해주세요.");
}
return cmntPswd;
};
const handleUpdateComment = async (cmntNo: number, wrtrNm: string) => {
if (!(await confirm("수정하시겠습니까?"))) return;
const cmntPswd = await getCommentPassword();
updateComment({
cmntNo,
data: {
cmntCn: editValue,
wrtrNm,
cmntPswd,
},
});
};
const handleDeleteComment = async (cmntNo: number) => {
if (!(await confirm("삭제하시겠습니까?"))) return;
const cmntPswd = await getCommentPassword();
deleteComment({ cmntNo, cmntPswd });
};
return (
<div className="card card-border !p-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="cmntCn"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
maxLength={1000}
className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6"
placeholder="댓글을 입력해주세요."
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 flex items-center justify-between">
<div className="flex gap-4">
{!userInfo?.userName && (
<FormField
control={form.control}
name="wrtrNm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[16rem] bg-background !text-[1.3rem]"
placeholder="작성자"
/>
</FormControl>
</FormItem>
)}
/>
)}
{!userInfo?.userUnqId && (
<FormField
control={form.control}
name="cmntPswd"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="password"
value={field.value ?? ""}
className="w-[12rem] bg-background !text-[1.3rem]"
placeholder="비밀번호"
/>
</FormControl>
</FormItem>
)}
/>
)}
</div>
<Button type="submit">등록</Button>
</div>
</form>
</Form>
<div className="mt-8 flex flex-col">
{comments?.content.map((comment) => {
if (comment.delYn === "Y") return null;
return (
<div
className="flex justify-between gap-2 border-t border-gray-200 py-8"
key={comment.cmntNo}
>
<div className="flex w-full gap-10">
<div className="flex w-[10rem] shrink-0 flex-col">
<span className="text-[1.4rem] font-bold text-label">
{comment.wrtrNm}
</span>
<span className="text-[1.2rem] text-label">
{dayjs(comment.regDt).format("YYYY.MM.DD")}
</span>
</div>
{editingCmntNo === comment.cmntNo ? (
<div className="flex flex-1 flex-col gap-2">
<Textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
maxLength={1000}
className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6"
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() =>
handleUpdateComment(comment.cmntNo, comment.wrtrNm)
}
>
저장
</Button>
<Button
size="sm"
color="gray"
onClick={() => {
setEditingCmntNo(null);
setEditValue("");
}}
>
취소
</Button>
</div>
</div>
) : (
<p className="break-all text-[1.4rem] text-foreground">
{comment.cmntCn}
</p>
)}
</div>
<div className="flex gap-2 whitespace-nowrap">
<Button
size="icon"
color="white"
onClick={() => {
setEditingCmntNo(comment.cmntNo);
setEditValue(comment.cmntCn);
}}
>
<i className="diveicon di-pencil" />
</Button>
<Button
size="icon"
color="white"
onClick={() => handleDeleteComment(comment.cmntNo)}
>
<i className="diveicon di-trash" />
</Button>
</div>
</div>
);
})}
</div>
</div>
);
}
export { PostComment };
+452
View File
@@ -0,0 +1,452 @@
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { UseFormReturn, FieldPath } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Input,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Textarea,
Button,
} from "@common/components";
import { BOARD_POSTING_CODE } from "@/entities/board";
import { AttachmentApi } from "@/entities/attachment";
import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useUserStore, setCookie, useMenuStore } from "@/shared/lib";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { PostingPeriodSelector } from "./posting-period-selector";
import { GeneralFileUploader } from "./general-file-uploader";
import { WithPreviewFileUploader } from "./with-preview-file-uploader";
type PostFormProps<T extends PostFormData | PostUpdateFormData> = {
form: UseFormReturn<T>;
handleSave: (data: T) => Promise<Post>;
};
function PostForm<T extends PostFormData | PostUpdateFormData>({
form,
handleSave,
}: PostFormProps<T>) {
const atflCd = form.getValues("atflCd" as FieldPath<T>);
const router = useRouter();
const { confirm } = useConfirm();
const { bbsInfo } = useMenuStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]);
const { data: attachmentData } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd],
queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd as string),
enabled: !!atflCd,
});
const onSubmit = async (data: T) => {
if (await confirm("저장하시겠습니까?")) {
if (deleteAttachmentIds.length > 0) {
await Promise.all(
deleteAttachmentIds.map(AttachmentApi.deleteAttachmentByAtflId),
);
}
await handleSave(data);
}
};
useEffect(() => {
setCookie("atfl-cd-key", "");
}, []);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="85%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>제목</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"pstTtl" as FieldPath<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={(field.value as string) ?? ""}
className="!text-[1.3rem]"
placeholder="제목을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>작성자</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"wrtrNm" as FieldPath<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={(field.value as string) ?? ""}
className="w-[20rem] !text-[1.3rem]"
placeholder="작성자를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{!userInfo?.userUnqId && (
<TableRow>
<TableHead>비밀번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"pstPswd" as FieldPath<T>}
render={({ field }) => (
<FormItem className="flex flex-col">
<FormControl>
<Input
{...field}
type="password"
value={(field.value as string) ?? ""}
className="w-[20rem] !text-[1.3rem]"
placeholder="비밀번호를 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
<TableRow>
<TableHead>내용</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"pstCn" as FieldPath<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
{...field}
value={(field.value as string) ?? ""}
onChange={field.onChange}
className="h-[20rem] w-full bg-form !text-[1.3rem] !leading-6"
placeholder="내용을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{bbsInfo?.rfrncUseYn === "Y" && (
<TableRow>
<TableHead>참조 URL</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"rfrncUrl" as FieldPath<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={(field.value as string) ?? ""}
className="!text-[1.3rem]"
placeholder="URL을 입력해주세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
)}
{bbsInfo?.popupUseYn === "Y" && (
<TableRow>
<TableHead>팝업여부</TableHead>
<TableCell className="border">
<div className="flex items-center gap-4">
<FormField
control={form.control}
name={"popupYn" as FieldPath<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
name="popupYn"
value={field.value?.toString() ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="popupY" />
<Label htmlFor="popupY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="popupN" />
<Label htmlFor="popupN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={"popupYnEtc" as FieldPath<T>}
render={({ field }) => {
const fieldValues = form.getValues().popupYnEtc?.[0];
return (
<FormItem>
<FormControl>
<PostingPeriodSelector
defaultValues={{ ...fieldValues }}
disabled={
form.getValues(
"popupYn" as FieldPath<T>,
) === "N"
}
handlePeriodValueChange={(
periodCode,
date,
) => {
field.onChange([
{
...fieldValues,
...date,
bbsPstgTypeCd:
BOARD_POSTING_CODE.팝업게시,
pstgPrdSeCd: periodCode,
},
]);
}}
handlePostingDateChange={(date) => {
field.onChange([
{ ...fieldValues, ...date },
]);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
</TableCell>
</TableRow>
)}
{bbsInfo?.upndFixUseYn === "Y" && (
<TableRow>
<TableHead>상단고정</TableHead>
<TableCell className="border">
<div className="flex items-center gap-4">
<FormField
control={form.control}
name={"upndFixYn" as FieldPath<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
name="upndFixYn"
value={field.value?.toString() ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="upndFixY" />
<Label htmlFor="upndFixY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="upndFixN" />
<Label htmlFor="upndFixN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={"upndFixYnEtc" as FieldPath<T>}
render={({ field }) => {
const fieldValues =
form.getValues().upndFixYnEtc?.[0];
return (
<FormItem>
<FormControl>
<PostingPeriodSelector
defaultValues={{ ...fieldValues }}
disabled={
form.getValues(
"upndFixYn" as FieldPath<T>,
) === "N"
}
handlePeriodValueChange={(
periodCode,
date,
) => {
field.onChange([
{
...fieldValues,
...date,
bbsPstgTypeCd:
BOARD_POSTING_CODE.상단게시,
pstgPrdSeCd: periodCode,
},
]);
}}
handlePostingDateChange={(date) => {
field.onChange([
{ ...fieldValues, ...date },
]);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
</TableCell>
</TableRow>
)}
{bbsInfo?.pstgPrdUseYn === "Y" && (
<TableRow>
<TableHead>게시기간</TableHead>
<TableCell className="border">
<div className="flex items-center gap-4">
<FormField
control={form.control}
name={"prdPstgYn" as FieldPath<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
name="prdPstgYn"
value={field.value?.toString() ?? ""}
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="prdPstgY" />
<Label htmlFor="prdPstgY">사용</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="prdPstgN" />
<Label htmlFor="prdPstgN">사용안함</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={"prdPstgYnEtc" as FieldPath<T>}
render={({ field }) => {
const fieldValues =
form.getValues().prdPstgYnEtc?.[0];
return (
<FormItem>
<FormControl>
<PostingPeriodSelector
defaultValues={{ ...fieldValues }}
disabled={
form.getValues(
"prdPstgYn" as FieldPath<T>,
) === "N"
}
handlePeriodValueChange={(
periodCode,
date,
) => {
field.onChange([
{
...fieldValues,
...date,
bbsPstgTypeCd:
BOARD_POSTING_CODE.기간게시,
pstgPrdSeCd: periodCode,
},
]);
}}
handlePostingDateChange={(date) => {
field.onChange([
{ ...fieldValues, ...date },
]);
}}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
</TableCell>
</TableRow>
)}
{bbsInfo?.atflUseYn === "Y" && (
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell className="border">
{bbsInfo?.bbsTypeCd === "BBSIMAGE" ? (
<WithPreviewFileUploader
form={form}
attachmentData={attachmentData}
/>
) : (
<GeneralFileUploader
form={form}
attachmentData={attachmentData}
deleteAttachmentIds={deleteAttachmentIds}
setDeleteAttachmentIds={setDeleteAttachmentIds}
/>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
size="lg"
color="white"
type="button"
onClick={() => router.back()}
>
취소
</Button>
<Button size="lg" type="submit">
저장
</Button>
</div>
</form>
</Form>
);
}
export { PostForm };
+236
View File
@@ -0,0 +1,236 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
useReactTable,
type PaginationState,
type RowSelectionState,
} from "@tanstack/react-table";
import {
Button,
DataTable,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
} from "@common/components";
import { PostApi, type Post, type PostParams } from "@/entities/post";
import type { BoardType } from "@/entities/board";
import { ENDPOINT } from "@/shared/config";
import { useMenuStore, useUserStore } from "@/shared/lib";
import { GridViewDataTable } from "@/shared/ui/grid-view-data-table";
import { columns } from "../model/post-table-columns";
import { createColumns } from "../model/grid-view-table-columns";
function PostPage({ bbsId }: { bbsId: string }) {
const router = useRouter();
const pathname = usePathname();
const { confirm } = useConfirm();
const { userInfo } = useUserStore((state) => state);
const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state);
const bbsType = bbsInfo?.bbsTypeCd as BoardType;
const [postParams, setPostParams] = useState<PostParams>({
keywordType: "title",
keyword: "",
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const thumbRefs = useRef<Map<number, string>>(new Map());
const { data: upndFixPostList } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, bbsInfo],
queryFn: () =>
PostApi.getPostList(bbsId, {
...postParams,
upndFixYn: bbsInfo?.upndFixUseYn,
}),
select: (data) => {
const newContent = data.content.map((post) => ({
...post,
isUpndFixPost: post.upndFixYn === "Y",
}));
return {
...data,
content: newContent,
};
},
enabled: bbsInfo?.upndFixUseYn === "Y",
});
const {
data: postList,
isFetched,
refetch,
} = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId],
queryFn: () =>
PostApi.getPostList(bbsId, {
...postParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
enabled: false,
});
const table = useReactTable({
data: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])],
columns,
state: { pagination, rowSelection },
rowCount: postList?.totalElements ?? 0,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
});
const gridViewTable = useReactTable({
data: postList?.content ?? [],
columns: createColumns(thumbRefs),
state: { pagination, rowSelection },
rowCount: postList?.totalElements ?? 0,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
});
const onPostDelete = async () => {
const selectedPosts = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original.pstNo);
if (await confirm("삭제하시겠습니까?")) {
setRowSelection({});
await Promise.all(
selectedPosts.map((pstNo) =>
PostApi.deletePost(bbsId, pstNo.toString(), userInfo?.userUnqId),
),
);
refetch();
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
refetch();
};
const handleRowClick = (row: Post) => {
router.push(`${pathname}/${row.pstNo}`);
};
useEffect(() => {
refetch();
}, [pagination]);
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label className="mr-2 text-label">검색</Label>
<Select
value={postParams.keywordType}
onValueChange={(keywordType) => {
setPostParams({ ...postParams, keywordType });
}}
>
<SelectTrigger className="h-[3.2rem] w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="title">제목</SelectItem>
<SelectItem value="all">제목+내용</SelectItem>
<SelectItem value="writer">작성자</SelectItem>
</SelectContent>
</Select>
<Input
value={postParams.keyword}
onChange={(e) =>
setPostParams({ ...postParams, keyword: e.target.value })
}
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">콘텐츠 목록</h3>
<div className="flex gap-2">
{pageMenuAuthority?.regAuthrtYn === "Y" && (
<Button onClick={() => router.push(`${pathname}/register`)}>
등록
</Button>
)}
{pageMenuAuthority?.delAuthrtYn === "Y" && (
<Button
color="red"
onClick={onPostDelete}
disabled={!table.getFilteredSelectedRowModel().rows.length}
>
선택삭제
</Button>
)}
<Select
value={table.getState().pagination.pageSize.toString()}
onValueChange={(value) => {
table.setPageSize(parseInt(value));
}}
>
<SelectTrigger className="h-[3.2rem] w-[12rem]">
<SelectValue placeholder="페이지 크기" />
</SelectTrigger>
<SelectContent>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={pageSize.toString()}>
{pageSize}개씩 보기
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
{isFetched ? (
bbsType === "BBSIMAGE" ? (
<GridViewDataTable
table={gridViewTable}
onRowClick={handleRowClick}
viewPagination
/>
) : (
<DataTable
table={table}
onRowClick={handleRowClick}
viewPagination
/>
)
) : (
<Skeleton className="h-[36rem] w-full" />
)}
</div>
</div>
</div>
);
}
export { PostPage };
+172
View File
@@ -0,0 +1,172 @@
"use client";
import dayjs from "dayjs";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Button, Skeleton } from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { PostApi } from "@/entities/post";
import {
useAlertStore,
useUserStore,
useMenuStore,
usePrompt,
} from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { PostAction } from "./post-action";
import { PostComment } from "./post-comment";
function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
const { prompt } = usePrompt();
const { setMessage: alert } = useAlertStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const { bbsInfo } = useMenuStore((state) => state);
const [filename, setFilename] = useState("");
const { data: postData } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId, pstNo],
queryFn: () => PostApi.getPostInfo(bbsId, pstNo),
});
const { data: downloadResponse, isFetching } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.DOWNLOAD, postData?.atflCd],
queryFn: () =>
AttachmentApi.downloadAttachmentByAtflCd(postData?.atflCd as string),
enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd !== "BBSIMAGE",
});
const { data: imageUrl, isLoading } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, postData?.atflCd],
queryFn: () =>
AttachmentApi.getImageUrlByAtflCd(postData?.atflCd as string),
enabled: !!postData?.atflCd,
});
const { mutate: deletePost } = useMutation({
mutationFn: (pstPswd: string) => PostApi.deletePost(bbsId, pstNo, pstPswd),
onSuccess: () => {
alert("삭제되었습니다.", router.back);
},
});
const handleDelete = async () => {
let pstPswd = "";
if (userInfo?.userUnqId) {
pstPswd = userInfo?.userUnqId;
} else {
pstPswd = await prompt("비밀번호를 입력해주세요.");
}
deletePost(pstPswd);
};
const handleDownload = async () => {
if (!downloadResponse) return;
try {
const blob = new Blob([downloadResponse.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename || "download";
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
} catch (error) {
console.error(error);
}
};
useEffect(() => {
if (!downloadResponse) return;
try {
let filename = "";
const contentDisposition =
downloadResponse.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = contentDisposition.match(
/filename\*=UTF-8''([^;]+)/,
);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1]);
}
}
setFilename(filename);
} catch {
setFilename("");
}
}, [downloadResponse]);
return (
<>
<PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={handleDelete}>
<div className="card card-border !p-8">
<h3 className="text-[2rem] font-bold text-foreground">
{postData?.pstTtl}
</h3>
<div className="card !m-0 !mt-8 bg-accent">
<ul className="flex gap-8 p-4 text-xl text-label">
<li>
작성자: <b>{postData?.wrtrNm}</b>
</li>
<li>
등록일: <b>{dayjs(postData?.regDt).format("YYYY-MM-DD")}</b>
</li>
<li>
조회수: <b>{postData?.inqNocs}</b>
</li>
</ul>
</div>
<hr className="mt-8" />
{bbsInfo?.bbsTypeCd === "BBSIMAGE" && (
<div className="relative my-6 flex h-[75rem] w-[100rem]">
{isLoading && <Skeleton className="h-full w-full" />}
{imageUrl && (
<Image
src={imageUrl}
className="object-cover"
fill
alt="썸네일 이미지"
/>
)}
</div>
)}
<pre className="font-noto min-h-[40rem] whitespace-pre-wrap p-8 text-2xl tracking-tighter text-foreground">
{postData?.pstCn ?? ""}
</pre>
{isFetching ? (
<div className="p-8">
<Skeleton className="h-[2.5rem] w-[32rem]" />
</div>
) : (
filename && (
<>
<hr />
<div className="flex items-center gap-4 p-8">
<span className="font-bold text-label">첨부파일</span>
<Button
size="sm"
color="white"
className="flex w-fit items-center gap-2"
onClick={handleDownload}
>
<i className="diveicon di-download" />
<span>{filename}</span>
</Button>
</div>
</>
)
)}
</div>
</PostAction>
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
</>
);
}
export { Post };
@@ -0,0 +1,86 @@
import dayjs from "dayjs";
import { useState } from "react";
import { CodeSelect } from "@/entities/code";
import { DateRangePicker } from "@common/components";
type PostingDate = {
pstgBgngDt: string | undefined;
pstgEndDt: string | undefined;
};
function PostingPeriodSelector({
defaultValues,
disabled,
handlePeriodValueChange,
handlePostingDateChange,
}: {
defaultValues?: {
pstgPrdSeCd?: string | null;
pstgBgngDt?: string | null;
pstgEndDt?: string | null;
};
disabled: boolean;
handlePeriodValueChange: (periodCode: string, date?: PostingDate) => void;
handlePostingDateChange: (date?: PostingDate) => void;
}) {
const [periodCode, setPeriodCode] = useState<string | null>(null);
const onPeriodCodeChange = (periodCode: string) => {
setPeriodCode(periodCode);
handlePeriodValueChange(periodCode, {
pstgBgngDt: `${dayjs().format("YYYY-MM-DD 00:00:00")}`,
pstgEndDt: `${dayjs().add(parseInt(periodCode), "day").format("YYYY-MM-DD 23:59:59")}`,
});
};
const onDateChange = (date?: {
from: Date | undefined;
to?: Date | undefined;
}) => {
handlePostingDateChange({
pstgBgngDt: dayjs(date?.from).format("YYYY-MM-DD 00:00:00"),
pstgEndDt: dayjs(date?.to).format("YYYY-MM-DD 23:59:59"),
});
};
const postingPeriodFormat = (periodCode: number | null) => {
if (!periodCode) return "";
if (periodCode === 99) return "상시";
return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs()
.add(periodCode, "day")
.format("YYYY-MM-DD")}`;
};
return (
<div className="flex items-center gap-4">
<CodeSelect
upCd="PSTGPRDCD"
defaultValue={defaultValues?.pstgPrdSeCd ?? ""}
onValueChange={onPeriodCodeChange}
disabled={disabled}
placeholder="기간 선택"
/>
{periodCode ? (
<span className="text-lg text-label">
게시 기간: <u>{postingPeriodFormat(parseInt(periodCode))}</u>
</span>
) : (
defaultValues?.pstgBgngDt &&
defaultValues?.pstgEndDt && (
<span className="text-lg text-label">
게시 기간:{" "}
<u>
{dayjs(defaultValues?.pstgBgngDt).format("YYYY-MM-DD")} ~{" "}
{dayjs(defaultValues?.pstgEndDt).format("YYYY-MM-DD")}
</u>
</span>
)
)}
{periodCode === "0" && (
<DateRangePicker handleDateChange={onDateChange} />
)}
</div>
);
}
export { PostingPeriodSelector };
@@ -0,0 +1,107 @@
import Image from "next/image";
import { useEffect, useState } from "react";
import type { HttpResponse } from "tus-js-client";
import type { FieldPath, UseFormReturn } from "react-hook-form";
import { FileUploader, Label, Progress } from "@common/components";
import { handleTusUpload } from "@/entities/tus";
import { AttachmentApi, type Attachment } from "@/entities/attachment";
import type { PostFormData, PostUpdateFormData } from "@/entities/post";
import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib";
function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({
form,
attachmentData,
}: {
form: UseFormReturn<T>;
attachmentData?: Attachment[];
}) {
const { setMessage: alert } = useAlertStore((state) => state);
const { bbsInfo } = useMenuStore((state) => state);
const [thumbnailUrl, setThumbnailUrl] = useState("");
const [progress, setProgress] = useState(0);
const handleTusResponse = async (res: HttpResponse) => {
const fileInfo = res.getHeader("X-File-Info");
if (
typeof fileInfo !== "string" ||
fileInfo === undefined ||
fileInfo === null
)
return;
const { atflCd, atflId } = JSON.parse(fileInfo) ?? {};
if (atflCd) form.setValue("atflCd" as FieldPath<T>, atflCd);
if (atflId) {
const imageUrl = await AttachmentApi.getImageUrl(atflId);
setThumbnailUrl(imageUrl);
}
};
const handleProgress = (percentage: string) => {
setProgress(parseInt(percentage));
};
const onUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
if (bbsInfo?.atflMaxCnt && files.length > bbsInfo.atflMaxCnt) {
alert("첨부파일 최대 개수를 초과했습니다.");
return;
}
for (const file of files) {
const fileSize = Math.round(file.size / 1024); // Byte -> KB
if (bbsInfo?.atflMaxFileSz && fileSize > bbsInfo.atflMaxFileSz) {
alert("첨부파일 최대 크기를 초과했습니다.");
return;
}
setCookie("atfl-cd-key", "");
handleTusUpload(file, {
handleResponse: handleTusResponse,
handleProgress,
});
}
};
useEffect(() => {
if (!attachmentData || attachmentData.length === 0) return;
(async () => {
const imageUrl = await AttachmentApi.getImageUrl(
attachmentData[0]?.atflId as string,
);
setThumbnailUrl(imageUrl);
})();
}, [attachmentData]);
return (
<FileUploader
id="atflCd"
className="!h-auto !max-w-none flex-col !items-start gap-4"
accept=".jpeg,.jpg,.png"
onUpload={onUpload}
customPreview={
<Label htmlFor="atflCd">
<div className="relative h-[27rem] w-[48rem] overflow-hidden rounded-sm border border-border bg-accent">
<div className="relative h-full w-full">
{thumbnailUrl && (
<Image
src={thumbnailUrl}
className="object-contain"
fill
alt="업로드된 이미지"
/>
)}
</div>
<Progress
value={progress}
className="absolute bottom-0 left-0 z-10"
/>
</div>
</Label>
}
/>
);
}
export { WithPreviewFileUploader };
+3
View File
@@ -181,6 +181,9 @@ importers:
'@minoru/react-dnd-treeview':
specifier: 3.5.1-alpha.1
version: 3.5.1-alpha.1(dnd-core@16.0.1)(react-dnd@16.0.1)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.2
version: 2.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1)