feat: 이미지 게시판 작업
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user