feat: 이미지 게시판 작업

This commit is contained in:
2025-05-13 10:54:02 +09:00
parent b202b4a99d
commit 7f70811c88
21 changed files with 609 additions and 66 deletions
@@ -0,0 +1,10 @@
import { ImagePost } from "@/pages/(pst)/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/(pst)/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/(pst)/image";
export default async function PstBbsImageRegisterPage({
params,
}: {
params: Promise<{ bbsId: string }>;
}) {
const { bbsId } = await params;
return <CreatePage bbsId={bbsId} />;
}
@@ -1,21 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import type { Board } from "@/entities/board";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
import { useLocalStorage } from "@frontend-opensource/use-react-hooks";
export default function PostLayout({
children,
}: {
children: React.ReactNode;
}) {
const [bbsInfo, setBbsInfo] = useState<Board | null>(null);
const [storedBbsInfo] = useLocalStorage("bbs-info", "");
useEffect(() => {
if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo));
}, [storedBbsInfo]);
const { bbsInfo } = useMenuStore((state) => state);
return (
<PageLayout
+1
View File
@@ -16,6 +16,7 @@
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "^3.5.0",
"@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",
@@ -3,6 +3,7 @@ export {
boardFormSchema,
BOARD_POSTING_CODE,
type Board,
type BoardType,
type BoardFormData,
type BoardParams,
type BoardPopup,
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useLocalStorage } from "@frontend-opensource/use-react-hooks";
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { SITE_ID } from "@/shared/config";
@@ -13,8 +14,14 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
const pathname = usePathname();
const { setMessage: alert } = useAlertStore((state) => state);
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
useMenuStore((state) => state);
const {
pageMenuAuthority,
setBbsInfo,
setCurrentMenuInfo,
setPageMenuAuthority,
} = useMenuStore((state) => state);
const [storedBbsInfo] = useLocalStorage("bbs-info", "");
const getCurrentMenuInfo = async () => {
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
@@ -53,6 +60,11 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/"));
}, [pageMenuAuthority]);
useEffect(() => {
if (!storedBbsInfo) return;
setBbsInfo(JSON.parse(storedBbsInfo));
}, [storedBbsInfo]);
return <>{children}</>;
};
@@ -0,0 +1,3 @@
export { CreatePage } from "./ui/create-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";
@@ -1,5 +1,39 @@
import { createStore } from "zustand/vanilla";
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;
@@ -29,11 +63,13 @@ interface IAuthority {
}
export type MenuState = {
bbsInfo: IBbs | null;
currentMenuInfo: IMenu | null;
pageMenuAuthority: IAuthority | null;
};
export type MenuActions = {
setBbsInfo: (bbs: IBbs) => void;
setCurrentMenuInfo: (menu: IMenu) => void;
setPageMenuAuthority: (authority: IAuthority) => void;
};
@@ -42,12 +78,14 @@ 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,
setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }),
setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }),
setPageMenuAuthority: (authority: IAuthority) =>
set({ pageMenuAuthority: authority }),
@@ -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 };
@@ -0,0 +1,117 @@
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>
<div className="flex items-start gap-2 text-left">
<span className="text-3xl font-bold leading-none">{pstTtl}</span>
</div>
</div>
);
},
},
{
id: "atflCd",
cell: ({ row }) => {
const atflCd = row.original.atflCd;
if (!atflCd) return null;
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">{rgtrNm}</span>;
},
},
{
id: "regDt",
cell: ({ row }) => (
<span className="text-[1.2rem]">
{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 null;
return (
<div className="relative my-6 flex h-[16rem] w-full items-center justify-center">
<Image
src={imageUrl}
className="object-contain"
fill
alt="썸네일 이미지"
/>
</div>
);
};
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import type { FieldPath, UseFormReturn } from "react-hook-form";
import type { HttpResponse, Upload } from "tus-js-client";
import { useLocalStorage } from "@frontend-opensource/use-react-hooks";
import { Button, FileUploader } from "@common/components";
import type { Board } from "@/entities/board";
import { handleTusUpload } from "@/entities/tus";
import { type Attachment } from "@/entities/attachment";
import type { PostFormData, PostUpdateFormData } from "@/entities/post";
import { setCookie, useAlertStore } from "@/shared/lib";
import { setCookie, useAlertStore, useMenuStore } from "@/shared/lib";
function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({
form,
@@ -20,10 +18,9 @@ function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({
deleteAttachmentIds: string[];
setDeleteAttachmentIds: (deleteAttachmentIds: string[]) => void;
}) {
const [storedBbsInfo] = useLocalStorage("bbs-info", "");
const { setMessage: alert } = useAlertStore((state) => state);
const { bbsInfo } = useMenuStore((state) => state);
const [bbsInfo, setBbsInfo] = useState<Board | null>(null);
const [attachments, setAttachments] = useState<Record<string, Attachment>>(
{},
);
@@ -88,10 +85,6 @@ function GeneralFileUploader<T extends PostFormData | PostUpdateFormData>({
if (attachmentData) setAttachments(mapper(attachmentData));
}, [attachmentData]);
useEffect(() => {
if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo));
}, [storedBbsInfo]);
return (
<FileUploader
id="atflCd"
@@ -1,11 +1,7 @@
import { useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import {
useConfirm,
useLocalStorage,
} from "@frontend-opensource/use-react-hooks";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
import type { Board } from "@/entities/board";
import { useMenuStore } from "@/shared/lib";
type PostActionProps = {
bbsId: string;
@@ -18,17 +14,12 @@ function PostAction({ children, handleDelete }: PostActionProps) {
const router = useRouter();
const pathname = usePathname();
const { confirm } = useConfirm();
const [storedBbsInfo] = useLocalStorage("bbs-info", "");
const [bbsInfo, setBbsInfo] = useState<Board | null>(null);
const { bbsInfo } = useMenuStore((state) => state);
const onDelete = async () => {
if (await confirm("삭제하시겠습니까?")) handleDelete();
};
useEffect(() => {
if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo));
}, [storedBbsInfo]);
return (
<div>
{children}
@@ -2,10 +2,7 @@ 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,
useLocalStorage,
} from "@frontend-opensource/use-react-hooks";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Input,
Label,
@@ -19,14 +16,15 @@ import {
Textarea,
Button,
} from "@common/components";
import { BOARD_POSTING_CODE, type Board } from "@/entities/board";
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 } from "@/shared/lib";
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>;
@@ -42,10 +40,9 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({
const router = useRouter();
const { confirm } = useConfirm();
const { bbsInfo } = useMenuStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const [storedBbsInfo] = useLocalStorage("bbs-info", "");
const [bbsInfo, setBbsInfo] = useState<Board | null>(null);
const [deleteAttachmentIds, setDeleteAttachmentIds] = useState<string[]>([]);
const { data: attachmentData } = useQuery({
@@ -65,10 +62,6 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({
}
};
useEffect(() => {
if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo));
}, [storedBbsInfo]);
useEffect(() => {
setCookie("atfl-cd-key", "");
}, []);
@@ -419,12 +412,19 @@ function PostForm<T extends PostFormData | PostUpdateFormData>({
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell className="border">
<GeneralFileUploader
form={form}
attachmentData={attachmentData}
deleteAttachmentIds={deleteAttachmentIds}
setDeleteAttachmentIds={setDeleteAttachmentIds}
/>
{bbsInfo?.bbsTypeCd === "BBSIMAGE" ? (
<WithPreviewFileUploader
form={form}
attachmentData={attachmentData}
/>
) : (
<GeneralFileUploader
form={form}
attachmentData={attachmentData}
deleteAttachmentIds={deleteAttachmentIds}
setDeleteAttachmentIds={setDeleteAttachmentIds}
/>
)}
</TableCell>
</TableRow>
)}
@@ -1,11 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
useConfirm,
useLocalStorage,
} from "@frontend-opensource/use-react-hooks";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
@@ -23,22 +20,24 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
} from "@common/components";
import { PostApi, type Post, type PostParams } from "@/entities/post";
import type { Board } from "@/entities/board";
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 [storedBbsInfo] = useLocalStorage("bbs-info", "");
const { pageMenuAuthority } = useMenuStore((state) => state);
const { userInfo } = useUserStore((state) => state);
const { bbsInfo, pageMenuAuthority } = useMenuStore((state) => state);
const bbsType = bbsInfo?.bbsTypeCd as BoardType;
const [bbsInfo, setBbsInfo] = useState<Board | null>(null);
const [postParams, setPostParams] = useState<PostParams>({
keywordType: "title",
keyword: "",
@@ -49,6 +48,8 @@ function PostPage({ bbsId }: { bbsId: string }) {
});
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: () =>
@@ -69,7 +70,11 @@ function PostPage({ bbsId }: { bbsId: string }) {
enabled: bbsInfo?.upndFixUseYn === "Y",
});
const { data: postList, refetch } = useQuery({
const {
data: postList,
isFetched,
refetch,
} = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_USER, bbsId],
queryFn: () =>
PostApi.getPostList(bbsId, {
@@ -92,6 +97,18 @@ function PostPage({ bbsId }: { bbsId: string }) {
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()
@@ -117,10 +134,6 @@ function PostPage({ bbsId }: { bbsId: string }) {
router.push(`${pathname}/${row.pstNo}`);
};
useEffect(() => {
if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo));
}, [storedBbsInfo]);
useEffect(() => {
refetch();
}, [pagination]);
@@ -197,7 +210,23 @@ function PostPage({ bbsId }: { bbsId: string }) {
</div>
</div>
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
<DataTable table={table} onRowClick={handleRowClick} viewPagination />
{isFetched ? (
bbsType === "BBSIMAGE" ? (
<GridViewDataTable
table={gridViewTable}
onRowClick={handleRowClick}
viewPagination
/>
) : (
<DataTable
table={table}
onRowClick={handleRowClick}
viewPagination
/>
)
) : (
<Skeleton className="h-[36rem] w-full" />
)}
</div>
</div>
</div>
@@ -1,13 +1,19 @@
"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, usePrompt } from "@/shared/lib";
import {
useAlertStore,
useUserStore,
useMenuStore,
usePrompt,
} from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { PostAction } from "./post-action";
import { PostComment } from "./post-comment";
@@ -17,6 +23,8 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
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({
@@ -28,6 +36,13 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
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,
});
@@ -106,6 +121,19 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
</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>
)}
<div
className="min-h-[40rem] p-8"
dangerouslySetInnerHTML={{ __html: postData?.pstCn ?? "" }}
@@ -0,0 +1,97 @@
import Image from "next/image";
import { 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,
}: {
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) {
setThumbnailUrl(await AttachmentApi.getImageUrl(atflId));
}
};
const handleProgress = (percentage: string) => {
setProgress(parseInt(percentage));
};
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,
handleProgress,
});
}
};
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
@@ -60,6 +60,9 @@ importers:
'@minoru/react-dnd-treeview':
specifier: ^3.5.0
version: 3.5.0(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)