fix: build 에러 수정, RealGrid 테스트 key 갱신
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
import { ViewPopup as VViewPopup } from "@/pages/popup";
|
||||
|
||||
export default async function ViewPopup({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ popupSn: string }>;
|
||||
}) {
|
||||
const { popupSn } = await params;
|
||||
return <VViewPopup popupSn={popupSn} />;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ViewSurveyArticle } from "@/pages/survey";
|
||||
|
||||
export default async function ViewSurveyArticlePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ srvySn: string }>;
|
||||
}) {
|
||||
const { srvySn } = await params;
|
||||
return <ViewSurveyArticle srvySn={srvySn} />;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ViewSurveyResult } from "@/pages/survey";
|
||||
|
||||
export default async function ViewSurveyResultPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ srvySn: string }>;
|
||||
}) {
|
||||
const { srvySn } = await params;
|
||||
return <ViewSurveyResult srvySn={srvySn} />;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ if (typeof window !== "undefined") {
|
||||
window.JSZip = JSZip;
|
||||
}
|
||||
|
||||
RealGrid.setLicenseKey('upVcPE+wPOmtLjqyBIh9RkM/nBOseBrflwxYpzGZyYm9cY8amGDkiPSoO0A3bI0LS1+jeXPBr4vmDWGWN+mYwx6C1EI62/mceibHCCl3OrbDYCIvknJkCQqgn3X6yKqZI9h9wRtIvbtWMBMOPHCENQuxnxtVqI2reHIsW5fXYGA=');
|
||||
RealGrid.setLicenseKey(process.env.NEXT_PUBLIC_ST_REALGRID_KEY || '');
|
||||
|
||||
const DataGrid = ({ columnInfo, rows = [], rowStartNum, isExcelImport = true, fixedColumns }: DataGridProps) => {
|
||||
const { columns, columnLayout } = columnInfo;
|
||||
|
||||
@@ -24,8 +24,8 @@ const getSidoListFetcher = async (): Promise<ApiResponse<Sido[]>> => {
|
||||
return statisticsAPi.getSidoList();
|
||||
};
|
||||
|
||||
const getSggListFetcher = async ([_key, sidoName]: [string, string]): Promise<ApiResponse<Sgg[]>> => {
|
||||
return statisticsAPi.getSggList(sidoName);
|
||||
const getSggListFetcher = async (key: [string, string]): Promise<ApiResponse<Sgg[]>> => {
|
||||
return statisticsAPi.getSggList(key[1]);
|
||||
};
|
||||
|
||||
const SearchForm = (props: SearchFormProps = {}) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface Sido {
|
||||
adstDstrCd: string;
|
||||
sido: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { type UseQueryResult } from "@tanstack/react-query";
|
||||
import type { PageableList } from "../model";
|
||||
|
||||
@@ -9,24 +10,24 @@ import type { PageableList } from "../model";
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<PageableList<T>>,
|
||||
options: {
|
||||
Loading?: JSX.Element;
|
||||
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
|
||||
Empty?: JSX.Element;
|
||||
Loading?: ReactElement;
|
||||
Errored?: ReactElement | ((error: unknown) => ReactElement);
|
||||
Empty?: ReactElement;
|
||||
Success: (
|
||||
data: UseQueryResult<PageableList<T>> & {
|
||||
data: NonNullable<UseQueryResult<T>["data"]>;
|
||||
},
|
||||
) => JSX.Element;
|
||||
) => ReactElement;
|
||||
},
|
||||
): JSX.Element;
|
||||
): ReactElement;
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<PageableList<T>>,
|
||||
options: {
|
||||
Loading?: JSX.Element;
|
||||
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
|
||||
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
|
||||
Loading?: ReactElement;
|
||||
Errored?: ReactElement | ((error: unknown) => ReactElement);
|
||||
Success: (data: UseQueryResult<PageableList<T>>) => ReactElement;
|
||||
},
|
||||
): JSX.Element;
|
||||
): ReactElement;
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<PageableList<T>>,
|
||||
{
|
||||
@@ -35,12 +36,12 @@ export function matchQueryStatus<T>(
|
||||
Empty,
|
||||
Success,
|
||||
}: {
|
||||
Loading?: JSX.Element;
|
||||
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
|
||||
Empty?: JSX.Element;
|
||||
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
|
||||
Loading?: ReactElement;
|
||||
Errored?: ReactElement | ((error: unknown) => ReactElement);
|
||||
Empty?: ReactElement;
|
||||
Success: (data: UseQueryResult<PageableList<T>>) => ReactElement;
|
||||
},
|
||||
): JSX.Element {
|
||||
): ReactElement {
|
||||
if (query.isLoading && Loading) {
|
||||
return Loading;
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { DndTree } from "./ui/dnd-tree";
|
||||
export { EditableNode } from "./ui/editable-node";
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { NodeModel, TreeMethods } from "@minoru/react-dnd-treeview";
|
||||
import { Button, cn } from "@common/components";
|
||||
import { EditableNode } from "./editable-node";
|
||||
|
||||
interface TreeData extends Record<string, unknown> {
|
||||
children?: unknown[];
|
||||
}
|
||||
|
||||
type CustomNodeProps<T extends TreeData> = {
|
||||
node: NodeModel<T>;
|
||||
depth: number;
|
||||
isOpen: boolean;
|
||||
isSelected: boolean;
|
||||
isAdditionalParent: boolean;
|
||||
treeMethods: TreeMethods | null;
|
||||
readOnly: boolean;
|
||||
onToggle: (id: NodeModel["id"]) => void;
|
||||
onSelect: (node: NodeModel<T>) => void;
|
||||
updateNodeName: (id: NodeModel["id"], nodeName: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function CustomNode<T extends TreeData>({
|
||||
node,
|
||||
depth,
|
||||
isOpen,
|
||||
isSelected,
|
||||
isAdditionalParent,
|
||||
treeMethods,
|
||||
readOnly,
|
||||
onToggle,
|
||||
onSelect,
|
||||
updateNodeName,
|
||||
}: CustomNodeProps<T>) {
|
||||
const { id, text, data } = node;
|
||||
const indent = depth * 10 + 5;
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [nodeName, setNodeName] = useState(text);
|
||||
|
||||
const handleToggle = () => onToggle(id);
|
||||
const handleSelect = () => onSelect(node);
|
||||
|
||||
const handleSave = async () => {
|
||||
await updateNodeName(id, nodeName);
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditMode(false);
|
||||
setNodeName(text);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdditionalParent) treeMethods?.open(id);
|
||||
}, [isAdditionalParent]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ paddingInlineStart: indent }}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
|
||||
isSelected && "!bg-[rgba(32,148,250,0.2)]",
|
||||
!readOnly && "group",
|
||||
)}
|
||||
>
|
||||
{editMode ? (
|
||||
<EditableNode
|
||||
value={nodeName}
|
||||
onChange={(e) => setNodeName(e.target.value)}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center" onClick={handleSelect}>
|
||||
{data && data.children && data.children.length > 0 && (
|
||||
<i
|
||||
className={cn(
|
||||
"diveicon di-arrow-forward mr-2 text-base text-label",
|
||||
isOpen && "rotate-90",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[1.4rem] font-medium text-label">{text}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="invisible !bg-transparent group-hover:visible"
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
<i className="diveicon di-pencil" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CustomNode };
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { type ReactElement, useRef } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import {
|
||||
Tree,
|
||||
MultiBackend,
|
||||
getBackendOptions,
|
||||
type NodeModel,
|
||||
type TreeMethods,
|
||||
type DropOptions,
|
||||
type Classes,
|
||||
} from "@minoru/react-dnd-treeview";
|
||||
import { Button, cn } from "@common/components";
|
||||
import { CustomNode } from "./custom-node";
|
||||
|
||||
interface TreeData extends Record<string, unknown> {
|
||||
children?: unknown[];
|
||||
}
|
||||
|
||||
type EditableNodeProps = { depth: number };
|
||||
|
||||
type DndTreeProps<T extends TreeData> = {
|
||||
tree: NodeModel<T>[];
|
||||
selectedNodeId?: number;
|
||||
classes?: {
|
||||
wrapper?: string;
|
||||
} & Classes<T>;
|
||||
renderEditableNode?: (props: EditableNodeProps) => ReactElement;
|
||||
handleSelectNode?: (node: NodeModel<T>) => void;
|
||||
handleDropNode?: (newTree: NodeModel<T>[], options: DropOptions<T>) => void;
|
||||
updateNodeName?: (id: NodeModel["id"], nodeName: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function DndTree<T extends TreeData>({
|
||||
tree,
|
||||
selectedNodeId,
|
||||
classes,
|
||||
renderEditableNode,
|
||||
handleSelectNode,
|
||||
handleDropNode,
|
||||
updateNodeName,
|
||||
}: DndTreeProps<T>) {
|
||||
const treeMethods = useRef<TreeMethods>(null);
|
||||
|
||||
const isAdditionalParent = (node: NodeModel<T>): boolean => {
|
||||
const additionalNode = tree.find((n) => n.id === -1);
|
||||
if (!additionalNode) return false;
|
||||
return additionalNode?.parent === node.id;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[calc(100vh-33.5rem)] overflow-auto",
|
||||
classes?.wrapper,
|
||||
)}
|
||||
>
|
||||
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
|
||||
<Tree
|
||||
tree={tree}
|
||||
rootId={0}
|
||||
ref={treeMethods}
|
||||
render={(node: NodeModel<T>, { depth, isOpen, onToggle }) => {
|
||||
if (node.id === -1)
|
||||
return renderEditableNode?.({ depth }) ?? <></>;
|
||||
return (
|
||||
<CustomNode
|
||||
node={node}
|
||||
depth={depth}
|
||||
treeMethods={treeMethods.current}
|
||||
isOpen={isOpen}
|
||||
isSelected={selectedNodeId === node.id}
|
||||
isAdditionalParent={isAdditionalParent(node)}
|
||||
readOnly={typeof updateNodeName === "undefined"}
|
||||
onToggle={onToggle}
|
||||
onSelect={(node) => handleSelectNode?.(node)}
|
||||
updateNodeName={(id, nodeName) =>
|
||||
updateNodeName?.(id, nodeName) ?? Promise.resolve()
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
onDrop={(newTree, options) => handleDropNode?.(newTree, options)}
|
||||
classes={{
|
||||
root: "p-4",
|
||||
container: "flex flex-col",
|
||||
dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm",
|
||||
placeholder: "relative",
|
||||
...classes,
|
||||
}}
|
||||
sort={false}
|
||||
dropTargetOffset={5}
|
||||
insertDroppableFirst={false}
|
||||
canDrop={(_, { dragSource, dropTargetId }) => {
|
||||
if (dragSource?.parent === dropTargetId) {
|
||||
return true;
|
||||
}
|
||||
}}
|
||||
placeholderRender={(_, { depth }) => {
|
||||
return (
|
||||
<div
|
||||
className="absolute right-0 top-0 h-[0.2rem] bg-primary"
|
||||
style={{ left: depth * 10 + 5 }}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
color="lightGray"
|
||||
className="flex-1 items-center px-0"
|
||||
onClick={() => {
|
||||
treeMethods.current?.openAll();
|
||||
}}
|
||||
>
|
||||
<i className="diveicon di-arrows-angle-expand" />
|
||||
전체펼침
|
||||
</Button>
|
||||
<Button
|
||||
color="lightGray"
|
||||
className="flex-1 items-center px-0"
|
||||
onClick={() => {
|
||||
treeMethods.current?.closeAll();
|
||||
}}
|
||||
>
|
||||
<i className="diveicon di-arrows-angle-contract" />
|
||||
전체접기
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { DndTree };
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
|
||||
type EditableNodeProps = {
|
||||
depth?: number;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
} & ComponentProps<typeof Input>;
|
||||
|
||||
function EditableNode({
|
||||
depth,
|
||||
onSave,
|
||||
onCancel,
|
||||
...props
|
||||
}: EditableNodeProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-sm bg-accent p-2"
|
||||
style={{ marginInlineStart: !depth ? 0 : depth * 5 + 5 }}
|
||||
>
|
||||
<Input {...props} className="h-[2.5rem] !text-[1.3rem] text-foreground" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 !bg-transparent"
|
||||
onClick={onSave}
|
||||
>
|
||||
<i className="diveicon di-check text-2xl" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 !bg-transparent"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<i className="diveicon di-x" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { EditableNode };
|
||||
@@ -1 +0,0 @@
|
||||
export { FormWrapper } from "./ui/form-wrapper";
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { type FieldValues, type UseFormReturn } from "react-hook-form";
|
||||
import { Button } from "@common/components";
|
||||
import { Form } from "@/shared/ui/form";
|
||||
|
||||
type FormWrapperProps<T extends FieldValues> = {
|
||||
form: UseFormReturn<T>;
|
||||
children: React.ReactNode;
|
||||
isModifyMode?: boolean;
|
||||
handleSave: (data: T) => Promise<unknown>;
|
||||
handleDelete?: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function FormWrapper<T extends FieldValues>({
|
||||
form,
|
||||
children,
|
||||
isModifyMode,
|
||||
handleSave,
|
||||
handleDelete,
|
||||
}: FormWrapperProps<T>) {
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const onSubmit = async (data: T) => {
|
||||
const confirmMessage = isModifyMode
|
||||
? "수정하시겠습니까?"
|
||||
: "저장하시겠습니까?";
|
||||
|
||||
if (await confirm(confirmMessage)) {
|
||||
await handleSave(data);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (await confirm("삭제하시겠습니까?")) {
|
||||
await handleDelete?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{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="submit">
|
||||
저장
|
||||
</Button>
|
||||
{isModifyMode && handleDelete && (
|
||||
<Button size="lg" color="red" type="button" onClick={onDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormWrapper };
|
||||
@@ -1,14 +0,0 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type {
|
||||
PostEvaluationPayload,
|
||||
PostEvaluationService,
|
||||
} from "../model/post-evaluation-interface";
|
||||
|
||||
export class PostEvaluationApiService implements PostEvaluationService {
|
||||
public async postEvaluation(payload: PostEvaluationPayload) {
|
||||
await client.post(ENDPOINT.BOARD_SERVICE.POST_EVALUATION, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export const PostEvaluationApi = new PostEvaluationApiService();
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Post } from "./ui/post";
|
||||
export { PostForm } from "./ui/post-form";
|
||||
export { PostPage } from "./ui/post-page";
|
||||
export { SortSelect } from "./ui/sort-select";
|
||||
export { GeneralFileUploader } from "./ui/general-file-uploader";
|
||||
@@ -1,184 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import Image from "next/image";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge, Checkbox, Skeleton, cn } from "@common/components";
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import type { Post } from "@/entities/post";
|
||||
import type { BbsType } from "@/shared/store";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface TableMeta<TData> {
|
||||
bbsTypeCd?: BbsType;
|
||||
}
|
||||
}
|
||||
|
||||
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, table }) => {
|
||||
const { atflCd, pstNo, pstTtl, pstCn, inqNocs, rgtrNm, regDt } =
|
||||
row.original;
|
||||
const bbsTypeCd = table.options.meta?.bbsTypeCd;
|
||||
|
||||
if (bbsTypeCd === "BBSIMAGE") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<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>
|
||||
)}
|
||||
<span className="text-[1.6rem] font-bold text-label">
|
||||
{pstTtl}
|
||||
</span>
|
||||
</div>
|
||||
<ThumbImage
|
||||
atflCd={atflCd}
|
||||
pstNo={pstNo}
|
||||
thumbRefs={thumbRefs}
|
||||
bbsTypeCd={bbsTypeCd}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[1.4rem] font-bold text-label">
|
||||
{rgtrNm}
|
||||
</span>
|
||||
<span className="text-[1.2rem] text-label">
|
||||
{dayjs(regDt).format("YYYY-MM-DD")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bbsTypeCd === "BBSMOVIE") {
|
||||
return (
|
||||
<div className="-m-4">
|
||||
<ThumbImage
|
||||
atflCd={atflCd}
|
||||
pstNo={pstNo}
|
||||
thumbRefs={thumbRefs}
|
||||
bbsTypeCd={bbsTypeCd}
|
||||
classes={{
|
||||
image: "object-cover",
|
||||
}}
|
||||
/>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<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(regDt), "hour") < 24 && (
|
||||
<Badge color="blue">NEW</Badge>
|
||||
)}
|
||||
<span className="text-[1.6rem] font-bold text-label">
|
||||
{pstTtl}
|
||||
</span>
|
||||
</div>
|
||||
<p className="line-clamp-3 overflow-hidden text-[1.2rem] text-label">
|
||||
{pstCn}
|
||||
</p>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[1.4rem] font-bold text-label">
|
||||
{rgtrNm}
|
||||
</span>
|
||||
<span className="text-[1.2rem] text-label">
|
||||
{dayjs(regDt).format("YYYY-MM-DD")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type ThumbImageProps = {
|
||||
atflCd: string;
|
||||
pstNo: number;
|
||||
thumbRefs: React.MutableRefObject<Map<number, string>>;
|
||||
bbsTypeCd?: string;
|
||||
classes?: {
|
||||
image?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ThumbImage = ({
|
||||
atflCd,
|
||||
pstNo,
|
||||
thumbRefs,
|
||||
bbsTypeCd,
|
||||
classes,
|
||||
}: ThumbImageProps) => {
|
||||
const prevImage = thumbRefs.current.get(pstNo);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd],
|
||||
queryFn: () => {
|
||||
if (bbsTypeCd === "BBSIMAGE") {
|
||||
return AttachmentApi.getImageUrlByAtflCd(atflCd);
|
||||
} else if (bbsTypeCd === "BBSMOVIE") {
|
||||
return AttachmentApi.getMovieThumbnailUrlByAtflCd(atflCd);
|
||||
}
|
||||
},
|
||||
select: (url) => {
|
||||
if (url) thumbRefs.current.set(pstNo, url);
|
||||
return url;
|
||||
},
|
||||
enabled: !prevImage && !!atflCd,
|
||||
});
|
||||
|
||||
const imageUrl = prevImage ?? data;
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="h-[16rem] w-full">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (imageUrl)
|
||||
return (
|
||||
<div className="relative flex h-[16rem] w-full items-center justify-center">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
className={cn("object-contain", classes?.image)}
|
||||
fill
|
||||
alt="썸네일 이미지"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[16rem] w-full items-center justify-center rounded-sm border border-border bg-background">
|
||||
<i className="diveicon di-image text-3xl" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface PostEvaluationPayload {
|
||||
pstNo: number;
|
||||
evlIp?: string;
|
||||
evlDt?: string;
|
||||
evlScr: number;
|
||||
}
|
||||
|
||||
export interface PostEvaluationService {
|
||||
postEvaluation: (payload: PostEvaluationPayload) => Promise<void>;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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, table }) => {
|
||||
if (row.original.isUpndFixPost) {
|
||||
return (
|
||||
<Badge
|
||||
color="orange"
|
||||
className="flex size-8 items-center justify-center"
|
||||
>
|
||||
<i className="diveicon di-volume-up-fill" />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const totalRows = table.getRowCount();
|
||||
const currentPage = table.getState().pagination.pageIndex;
|
||||
const pageSize = table.getState().pagination.pageSize;
|
||||
return totalRows - (currentPage * pageSize + row.index);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "pstTtl",
|
||||
header: "제목",
|
||||
cell: ({ row }) => {
|
||||
const { ansPstn = 0, pstTtl, inqNocs, cmntCnt } = row.original;
|
||||
const indentPadding = ansPstn > 0 ? (ansPstn - 1) * 20 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-[20vw] items-center gap-2 text-left"
|
||||
style={{ paddingInlineStart: `${indentPadding}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>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
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 { setCookie, useAlertStore, useMenuStore } from "@/shared/lib";
|
||||
|
||||
function GeneralFileUploader<T extends { atflCd?: string | null }>({
|
||||
form,
|
||||
attachmentData,
|
||||
deleteAttachmentIds,
|
||||
setDeleteAttachmentIds,
|
||||
}: {
|
||||
form: UseFormReturn<T>;
|
||||
attachmentData?: Attachment[];
|
||||
deleteAttachmentIds: string[];
|
||||
setDeleteAttachmentIds: (deleteAttachmentIds: string[]) => void;
|
||||
}) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const bbsInfo = useMenuStore((state) => state.bbsInfo);
|
||||
|
||||
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 items-start gap-4 [&>label]:h-[3.2rem]"
|
||||
onUpload={onUpload}
|
||||
customPreview={
|
||||
<div className="flex w-full flex-wrap 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 shrink-0 items-center justify-center rounded-full p-0"
|
||||
onClick={() =>
|
||||
setDeleteAttachmentIds([...deleteAttachmentIds, atflId])
|
||||
}
|
||||
>
|
||||
<i className="diveicon di-x text-xs" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { GeneralFileUploader };
|
||||
@@ -1,106 +0,0 @@
|
||||
import { toast } from "sonner";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useClipboard } from "@frontend-opensource/use-react-hooks";
|
||||
import { Button } from "@common/components";
|
||||
import { deleteReasonMessages } from "@/entities/post";
|
||||
import { AuthorityButton } from "@/entities/menu-authority";
|
||||
import { useMenuStore, useSelectPrompt, useUserStore } from "@/shared/lib";
|
||||
|
||||
type PostActionProps = {
|
||||
bbsId: string;
|
||||
pstNo: string;
|
||||
children: React.ReactNode;
|
||||
handleDelete: (delRsn?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const selectData = Object.entries(deleteReasonMessages).map(
|
||||
([label, value]) => ({ label, value }),
|
||||
);
|
||||
|
||||
function PostAction({ children, handleDelete }: PostActionProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { copyText, copied } = useClipboard();
|
||||
const { prompt } = useSelectPrompt(selectData);
|
||||
const bbsInfo = useMenuStore((state) => state.bbsInfo);
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const postPageLink = `/bbs/${bbsInfo?.bbsTypeCd.toLowerCase()}/${bbsInfo?.bbsId}`;
|
||||
|
||||
const onDelete = async () => {
|
||||
const { resolved, value } = await prompt("삭제 사유를 입력해주세요.");
|
||||
if (resolved) handleDelete(value);
|
||||
};
|
||||
|
||||
const checkAnsUseAuthrt = () => {
|
||||
// 관리자
|
||||
if (bbsInfo?.ansUseAuthrt === "A") {
|
||||
return userInfo?.adminRoleYn === "Y";
|
||||
}
|
||||
// 회원
|
||||
if (bbsInfo?.ansUseAuthrt === "M") {
|
||||
return !!userInfo;
|
||||
}
|
||||
// 비회원
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) toast.success("URL이 클립보드에 복사되었습니다.");
|
||||
}, [copied]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div className="flex justify-center gap-4 pb-12 pt-10">
|
||||
<Button
|
||||
size="lg"
|
||||
color="emerald"
|
||||
type="button"
|
||||
onClick={() => copyText(window.location.href)}
|
||||
>
|
||||
퍼가기
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
color="white"
|
||||
type="button"
|
||||
onClick={() => router.push(postPageLink)}
|
||||
>
|
||||
목록
|
||||
</Button>
|
||||
<AuthorityButton
|
||||
authority="mdfcnAuthrtYn"
|
||||
size="lg"
|
||||
type="button"
|
||||
onClick={() => router.push(`${pathname}/modify`)}
|
||||
>
|
||||
수정
|
||||
</AuthorityButton>
|
||||
{bbsInfo?.ansUseYn === "Y" && (
|
||||
<AuthorityButton
|
||||
authority="regAuthrtYn"
|
||||
size="lg"
|
||||
type="button"
|
||||
color="gray"
|
||||
onClick={() => router.push(`${pathname}/answer`)}
|
||||
disabled={!checkAnsUseAuthrt()}
|
||||
>
|
||||
답글
|
||||
</AuthorityButton>
|
||||
)}
|
||||
<AuthorityButton
|
||||
authority="delAuthrtYn"
|
||||
size="lg"
|
||||
color="red"
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
>
|
||||
삭제
|
||||
</AuthorityButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { PostAction };
|
||||
@@ -1,323 +0,0 @@
|
||||
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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
import {
|
||||
CommentApi,
|
||||
commentFormSchema,
|
||||
type CommentFormData,
|
||||
type CommentUpdateFormData,
|
||||
} from "@/entities/comment";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import {
|
||||
useAlertStore,
|
||||
useMenuStore,
|
||||
useMutationWrapper,
|
||||
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 bbsInfo = useMenuStore((state) => state.bbsInfo);
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [editingCmntNo, setEditingCmntNo] = useState<number | null>(null);
|
||||
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo],
|
||||
queryFn: () => CommentApi.getComments({ pstNo }),
|
||||
enabled: !!pstNo,
|
||||
});
|
||||
|
||||
const { mutate: createComment } = useMutationWrapper({
|
||||
mutationFn: (data: CommentFormData) => CommentApi.createComment(data),
|
||||
onSuccess: () => {
|
||||
alert("등록되었습니다.");
|
||||
form.setValue("cmntCn", "");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updateComment } = useMutationWrapper({
|
||||
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 } = useMutationWrapper({
|
||||
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 {
|
||||
const { resolved, value } = await prompt("비밀번호를 입력해주세요.");
|
||||
if (resolved) cmntPswd = value ?? "";
|
||||
}
|
||||
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)}>
|
||||
{bbsInfo?.cmntUseYn === "C" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cmntEvl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
name="cmntEvl"
|
||||
value={field.value?.toString() ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="5" id="cmntEvl5" />
|
||||
<Label htmlFor="cmntEvl5">매우만족 ★★★★★</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="4" id="cmntEvl4" />
|
||||
<Label htmlFor="cmntEvl4">만족 ★★★★</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="3" id="cmntEvl3" />
|
||||
<Label htmlFor="cmntEvl3">보통 ★★★</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="2" id="cmntEvl2" />
|
||||
<Label htmlFor="cmntEvl2">불만족 ★★</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="1" id="cmntEvl1" />
|
||||
<Label htmlFor="cmntEvl1">매우불만족 ★</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
key={comment.cmntNo}
|
||||
className="flex flex-col border-t border-gray-200 py-8"
|
||||
>
|
||||
<div>
|
||||
{Array.from({ length: Number(comment.cmntEvl) }).map(
|
||||
(_, index) => (
|
||||
<span key={index}>★</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PostComment };
|
||||
@@ -1,449 +0,0 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { UseFormReturn, Path } 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 Path<T>);
|
||||
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const bbsInfo = useMenuStore((state) => state.bbsInfo);
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
|
||||
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 data-required>제목</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"pstTtl" as Path<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 Path<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 data-required>비밀번호</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"pstPswd" as Path<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 Path<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 Path<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 Path<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 Path<T>}
|
||||
render={({ field }) => {
|
||||
const fieldValues = form.getValues().popupYnEtc?.[0];
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<PostingPeriodSelector
|
||||
defaultValues={{ ...fieldValues }}
|
||||
disabled={
|
||||
form.getValues("popupYn" as Path<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 Path<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 Path<T>}
|
||||
render={({ field }) => {
|
||||
const fieldValues =
|
||||
form.getValues().upndFixYnEtc?.[0];
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<PostingPeriodSelector
|
||||
defaultValues={{ ...fieldValues }}
|
||||
disabled={
|
||||
form.getValues("upndFixYn" as Path<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 Path<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 Path<T>}
|
||||
render={({ field }) => {
|
||||
const fieldValues =
|
||||
form.getValues().prdPstgYnEtc?.[0];
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<PostingPeriodSelector
|
||||
defaultValues={{ ...fieldValues }}
|
||||
disabled={
|
||||
form.getValues("prdPstgYn" as Path<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" ||
|
||||
bbsInfo?.bbsTypeCd === "BBSMOVIE" ? (
|
||||
<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 };
|
||||
@@ -1,239 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
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 { AuthorityButton } from "@/entities/menu-authority";
|
||||
import {
|
||||
PostApi,
|
||||
PostPageGuard,
|
||||
deleteReasonMessages,
|
||||
type Post,
|
||||
type PostParams,
|
||||
} from "@/entities/post";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useMenuStore, useSelectPrompt } from "@/shared/lib";
|
||||
import { GridViewDataTable } from "@/shared/ui/grid-view-data-table";
|
||||
import { columns as tableColumns } from "../model/post-table-columns";
|
||||
import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns";
|
||||
import { SortSelect } from "./sort-select";
|
||||
|
||||
const selectData = Object.entries(deleteReasonMessages).map(
|
||||
([label, value]) => ({ label, value }),
|
||||
);
|
||||
|
||||
function PostPage({ bbsId }: { bbsId: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { prompt } = useSelectPrompt(selectData);
|
||||
const bbsInfo = useMenuStore((state) => state.bbsInfo);
|
||||
const thumbRefs = useRef<Map<number, string>>(new Map());
|
||||
|
||||
const [tableSort, setTableSort] = useState("");
|
||||
const [postParams, setPostParams] = useState<PostParams>({
|
||||
keywordType: "title",
|
||||
keyword: "",
|
||||
});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
const { data: upndFixPostList } = useQuery({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, bbsInfo],
|
||||
queryFn: () =>
|
||||
PostApi.getPostList(parseInt(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,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
ENDPOINT.BOARD_SERVICE.POSTS,
|
||||
bbsInfo?.bbsTypeCd,
|
||||
pagination,
|
||||
tableSort,
|
||||
],
|
||||
queryFn: () =>
|
||||
PostApi.getPostList(parseInt(bbsId), {
|
||||
...postParams,
|
||||
bbsTypeCd: bbsInfo?.bbsTypeCd,
|
||||
ansUseYn: bbsInfo?.ansUseYn,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
sort: tableSort,
|
||||
}),
|
||||
enabled: !!bbsInfo?.bbsTypeCd,
|
||||
});
|
||||
|
||||
const isGridViewPage =
|
||||
bbsInfo?.bbsTypeCd === "BBSIMAGE" || bbsInfo?.bbsTypeCd === "BBSMOVIE";
|
||||
|
||||
const table = useReactTable({
|
||||
data: isGridViewPage
|
||||
? (postList?.content ?? [])
|
||||
: [...(upndFixPostList?.content ?? []), ...(postList?.content ?? [])],
|
||||
columns: isGridViewPage ? createGridViewColumns(thumbRefs) : tableColumns,
|
||||
state: { pagination, rowSelection },
|
||||
rowCount: postList?.totalElements ?? 0,
|
||||
manualPagination: true,
|
||||
enableMultiRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
meta: { bbsTypeCd: bbsInfo?.bbsTypeCd },
|
||||
});
|
||||
|
||||
const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable;
|
||||
|
||||
const onPostDelete = async () => {
|
||||
const selectedPosts = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original.pstNo);
|
||||
|
||||
const { resolved, value: delRsn } =
|
||||
await prompt("삭제 사유를 입력해주세요.");
|
||||
if (!resolved) return;
|
||||
setRowSelection({});
|
||||
await Promise.all(
|
||||
selectedPosts.map((pstNo) =>
|
||||
PostApi.deletePost(parseInt(bbsId), pstNo, delRsn),
|
||||
),
|
||||
);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: Post) => {
|
||||
router.push(`${pathname}/${row.pstNo}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<PostPageGuard bbsId={bbsId}>
|
||||
<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">
|
||||
<AuthorityButton
|
||||
authority="regAuthrtYn"
|
||||
onClick={() => router.push(`${pathname}/register`)}
|
||||
>
|
||||
등록
|
||||
</AuthorityButton>
|
||||
<AuthorityButton
|
||||
authority="delAuthrtYn"
|
||||
color="red"
|
||||
onClick={onPostDelete}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length}
|
||||
>
|
||||
선택삭제
|
||||
</AuthorityButton>
|
||||
<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>
|
||||
{bbsInfo?.ansUseYn === "N" && (
|
||||
<SortSelect value={tableSort} onValueChange={setTableSort} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card !m-[1.2rem] h-full overflow-auto !p-0">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[36rem] w-full" />
|
||||
) : (
|
||||
<DataTableComponent
|
||||
table={table}
|
||||
onRowClick={handleRowClick}
|
||||
viewPagination
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PostPageGuard>
|
||||
);
|
||||
}
|
||||
|
||||
export { PostPage };
|
||||
@@ -1,263 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Skeleton,
|
||||
} from "@common/components";
|
||||
import { AttachmentApi } from "@/entities/attachment";
|
||||
import { PostApi } from "@/entities/post";
|
||||
import { BoardApi } from "@/entities/board";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useMutationWrapper, useMenuStore } from "@/shared/lib";
|
||||
import { PostAction } from "./post-action";
|
||||
import { PostComment } from "./post-comment";
|
||||
import { PostEvaluationApi } from "../api/post-evaluation-service";
|
||||
|
||||
function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
|
||||
const router = useRouter();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { bbsInfo, setBbsInfo } = useMenuStore((state) => state);
|
||||
|
||||
const [filename, setFilename] = useState("");
|
||||
const [evlScr, setEvlScr] = useState(5);
|
||||
|
||||
const { data: boardInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, bbsId],
|
||||
queryFn: () => BoardApi.getBoardInfo(bbsId),
|
||||
});
|
||||
|
||||
const { data: postData } = useQuery({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, bbsId, pstNo],
|
||||
queryFn: () => PostApi.getPostInfo(parseInt(bbsId), parseInt(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" &&
|
||||
bbsInfo?.bbsTypeCd !== "BBSMOVIE",
|
||||
});
|
||||
|
||||
const { data: imageUrl, isLoading } = useQuery({
|
||||
queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, postData?.atflCd],
|
||||
queryFn: () =>
|
||||
AttachmentApi.getImageUrlByAtflCd(postData?.atflCd as string),
|
||||
enabled: !!postData?.atflCd,
|
||||
});
|
||||
|
||||
const { data: videoAttachment } = useQuery({
|
||||
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, postData?.atflCd],
|
||||
queryFn: () => AttachmentApi.getAttachmentsInfo(postData?.atflCd as string),
|
||||
select: (data) => data[0],
|
||||
enabled: !!postData?.atflCd && bbsInfo?.bbsTypeCd === "BBSMOVIE",
|
||||
});
|
||||
|
||||
const { data: videoUrl } = useQuery({
|
||||
queryKey: [ENDPOINT.ATTACH_SERVICE.STREAM_VIDEO, videoAttachment?.atflId],
|
||||
queryFn: () =>
|
||||
AttachmentApi.getStreamVideoUrlByAtflId(
|
||||
videoAttachment?.atflId as string,
|
||||
),
|
||||
enabled: !!videoAttachment?.atflId,
|
||||
});
|
||||
|
||||
const { mutateAsync: deletePost } = useMutationWrapper({
|
||||
mutationFn: (delRsn?: string) =>
|
||||
PostApi.deletePost(parseInt(bbsId), parseInt(pstNo), delRsn),
|
||||
onSuccess: () => {
|
||||
alert("삭제되었습니다.", router.back);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: postEvaluation } = useMutationWrapper({
|
||||
mutationFn: (evlScr: number) =>
|
||||
PostEvaluationApi.postEvaluation({
|
||||
pstNo: parseInt(pstNo),
|
||||
evlScr,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
alert("게시물 평가가 완료되었습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardInfo) return;
|
||||
setBbsInfo(boardInfo);
|
||||
}, [boardInfo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PostAction bbsId={bbsId} pstNo={pstNo} handleDelete={deletePost}>
|
||||
<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" && (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="relative my-6 flex h-[75rem] w-[100rem]">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
{imageUrl && (
|
||||
<div className="relative my-6 flex h-[75rem] w-[100rem]">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
className="object-cover"
|
||||
fill
|
||||
alt="썸네일 이미지"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{bbsInfo?.bbsTypeCd === "BBSMOVIE" && (
|
||||
<div className="relative my-6 flex h-[56.25rem] w-[100rem]">
|
||||
<video src={videoUrl} controls className="object-contain" />
|
||||
</div>
|
||||
)}
|
||||
<pre className="min-h-[40rem] whitespace-pre-wrap p-8 font-noto 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>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{bbsInfo?.dgstfnEvlUseYn === "Y" && (
|
||||
<>
|
||||
<hr />
|
||||
<div className="mt-8 flex items-center gap-2 px-4">
|
||||
<p>
|
||||
이 페이지에서 제공하는
|
||||
<br />
|
||||
정보에 대하여 만족하시나요?
|
||||
</p>
|
||||
<div className="ml-4 rounded-sm border border-border bg-accent px-8 py-4">
|
||||
<RadioGroup
|
||||
value={evlScr.toString()}
|
||||
onValueChange={(value) => setEvlScr(parseInt(value))}
|
||||
className="flex items-center space-x-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="5" id="evl5" />
|
||||
<Label htmlFor="evl5">매우 만족 😊</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="4" id="evl4" />
|
||||
<Label htmlFor="evl4">만족 🙂</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="3" id="evl3" />
|
||||
<Label htmlFor="evl3">보통 😐</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="2" id="evl2" />
|
||||
<Label htmlFor="evl2">불만족 🙁</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="1" id="evl1" />
|
||||
<Label htmlFor="evl1">매우 불만족 😞</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
color="white"
|
||||
onClick={() => postEvaluation(evlScr)}
|
||||
>
|
||||
평가하기
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PostAction>
|
||||
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Post };
|
||||
@@ -1,84 +0,0 @@
|
||||
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 onValueChange={onDateChange} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PostingPeriodSelector };
|
||||
@@ -1,32 +0,0 @@
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@common/components";
|
||||
|
||||
type SortSelectProps = {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
};
|
||||
|
||||
function SortSelect(props: SortSelectProps) {
|
||||
return (
|
||||
<Select {...props}>
|
||||
<SelectTrigger className="h-[3.2rem] w-[8rem]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pstTtl,asc">제목↑</SelectItem>
|
||||
<SelectItem value="pstTtl,desc">제목↓</SelectItem>
|
||||
<SelectItem value="rgtrNm,asc">작성자↑</SelectItem>
|
||||
<SelectItem value="rgtrNm,desc">작성자↓</SelectItem>
|
||||
<SelectItem value="regDt,asc">등록일↑</SelectItem>
|
||||
<SelectItem value="regDt,desc">등록일↓</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export { SortSelect };
|
||||
@@ -1,125 +0,0 @@
|
||||
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";
|
||||
import type { BbsType } from "@/shared/store";
|
||||
|
||||
function WithPreviewFileUploader<T extends PostFormData | PostUpdateFormData>({
|
||||
form,
|
||||
attachmentData,
|
||||
}: {
|
||||
form: UseFormReturn<T>;
|
||||
attachmentData?: Attachment[];
|
||||
}) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const bbsInfo = useMenuStore((state) => state.bbsInfo);
|
||||
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState("");
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const fileAccept = (bbsTypeCd: BbsType) => {
|
||||
switch (bbsTypeCd) {
|
||||
case "BBSIMAGE":
|
||||
return ".jpeg,.jpg,.png";
|
||||
case "BBSMOVIE":
|
||||
return ".mp4,.mov,.avi,.wmv,.mkv";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
let imageUrl = "";
|
||||
if (bbsInfo?.bbsTypeCd === "BBSIMAGE") {
|
||||
imageUrl = await AttachmentApi.getImageUrlByAtflId(atflId);
|
||||
}
|
||||
if (bbsInfo?.bbsTypeCd === "BBSMOVIE") {
|
||||
imageUrl = await AttachmentApi.getMovieThumbnailUrlByAtflId(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.getImageUrlByAtflId(
|
||||
attachmentData[0]?.atflId as string,
|
||||
);
|
||||
setThumbnailUrl(imageUrl);
|
||||
})();
|
||||
}, [attachmentData]);
|
||||
|
||||
return (
|
||||
<FileUploader
|
||||
id="atflCd"
|
||||
className="!h-auto !max-w-none flex-col !items-start gap-4 [&_.file-uploader-label]:h-[3.2rem]"
|
||||
accept={fileAccept(bbsInfo?.bbsTypeCd as BbsType)}
|
||||
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