feat: post 폴더 추가(cs)
This commit is contained in:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user