WIP: 게시글 첨부파일 작업중

This commit is contained in:
2025-05-02 18:22:40 +09:00
parent cbf582a18a
commit 1bf2919a09
9 changed files with 185 additions and 21 deletions
@@ -42,6 +42,10 @@ export class AttachmentApiService implements AttachmentService {
return URL.createObjectURL(blob);
}
public async deleteAttachmentByAtflId(atflId: string): Promise<void> {
await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`);
}
public async getImageUrlByAtflCd(atflCd: string): Promise<string> {
if (!atflCd) return "";
const response = await fetch(
@@ -36,6 +36,7 @@ export interface AttachmentService {
getAttachmentsList: (params: AttachmentParams) => Promise<AttachmentList>;
getAttachmentsInfo: (atflCd: string) => Promise<Attachment[]>;
deleteAttachment: (atflId: string) => Promise<void>;
deleteAttachmentByAtflId: (atflId: string) => Promise<void>;
deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>;
getImageUrl: (atflId: string) => Promise<string>;
getImageUrlByAtflCd: (atflCd: string) => Promise<string>;
@@ -0,0 +1 @@
export { handleTusUpload } from "./lib/utils";
@@ -0,0 +1,60 @@
import { Upload, type HttpResponse } from "tus-js-client";
import { getCookie, setCookie } from "@/shared/lib";
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
type UploadOptions = {
handleUploadStart?: (upload: Upload) => void;
handleProgress?: (percentage: string) => void;
handleSuccess?: (upload: Upload) => void;
handleResponse?: (res: HttpResponse, upload: Upload) => void;
};
export const handleTusUpload = (file: File, options?: UploadOptions) => {
let metadata = {};
if (!file) return;
metadata = {
filename: file.name,
filetype: file.type,
};
const atflCd = getCookie("atfl-cd-key");
if (atflCd) {
metadata = {
...metadata,
atflCd,
};
}
const upload = new Upload(file, {
endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
chunkSize: 10 * 1024 * 1024,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
metadata,
onError(error) {
console.error(`Failed because: ${error}`);
},
onProgress(bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
options?.handleProgress?.(percentage);
},
onAfterResponse(req, res) {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? "");
options?.handleResponse?.(res, upload);
},
onSuccess() {
options?.handleSuccess?.(upload);
},
});
upload.start();
options?.handleUploadStart?.(upload);
console.log("upload start >>>>>", upload);
};
@@ -53,9 +53,8 @@ export const columns: ColumnDef<Post>[] = [
const AttachmentButton = ({ atflCd }: { atflCd: string }) => {
if (!atflCd) return null;
return (
<Button size="sm" color="white" className="flex items-center gap-2">
<Button size="icon" color="white">
<i className="diveicon di-download" />
다운로드
</Button>
);
};
@@ -4,10 +4,9 @@ import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { postFormSchema, type PostFormData } from "@/entities/post";
import { PostApi } from "@/entities/post";
import { PostForm } from "@/widgets/post";
import { PostApi, postFormSchema, type PostFormData } from "@/entities/post";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { NoticeForm } from "./notice-form";
import { NoticeLayout } from "./notice-layout";
function CreatePage() {
@@ -41,7 +40,7 @@ function CreatePage() {
<div className="card-header">
<h3 className="text-2xl font-medium text-label">게시글 등록</h3>
</div>
<NoticeForm form={form} handleSave={createPost} />
<PostForm form={form} handleSave={createPost} />
</div>
</NoticeLayout>
);
@@ -3,6 +3,7 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { PostForm } from "@/widgets/post";
import {
PostApi,
postUpdateFormSchema,
@@ -10,7 +11,6 @@ import {
} from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { NoticeForm } from "./notice-form";
import { NoticeLayout } from "./notice-layout";
function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
@@ -40,7 +40,7 @@ function ModifyPage({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
<div className="card-header">
<h3 className="text-2xl font-medium text-label">게시글 수정</h3>
</div>
<NoticeForm form={form} handleSave={updatePost} />
<PostForm form={form} handleSave={updatePost} />
</div>
</NoticeLayout>
);
@@ -1 +1,2 @@
export { Post } from "./ui/post";
export { PostForm } from "./ui/post-form";
@@ -1,5 +1,7 @@
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse, Upload } from "tus-js-client";
import type { UseFormReturn, FieldPath } from "react-hook-form";
import { useLocalStorage } from "@frontend-opensource/use-react-hooks";
import {
@@ -14,34 +16,97 @@ import {
TableRow,
Textarea,
DateRangePicker,
FileUploader,
Button,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
import { CodeSelect } from "@/entities/code";
import type { Board } from "@/entities/board";
import { handleTusUpload } from "@/entities/tus";
import { AttachmentApi, type Attachment } from "@/entities/attachment";
import type { Post, PostFormData, PostUpdateFormData } from "@/entities/post";
import { FormControl, FormField, FormItem } from "@/shared/ui/form";
import { useUserStore } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
type NoticeFormProps<T extends PostFormData | PostUpdateFormData> = {
type PostFormProps<T extends PostFormData | PostUpdateFormData> = {
form: UseFormReturn<T>;
handleSave: (data: T) => Promise<Post>;
};
function NoticeForm<T extends PostFormData | PostUpdateFormData>({
function PostForm<T extends PostFormData | PostUpdateFormData>({
form,
handleSave,
}: NoticeFormProps<T>) {
}: PostFormProps<T>) {
const atflCd = form.watch("atflCd" as FieldPath<T>);
const { userInfo } = useUserStore((state) => state);
const [storedBbsInfo] = useLocalStorage("bbs-info", "");
const [bbsInfo, setBbsInfo] = useState<Board | null>(null);
const [attachments, setAttachments] = useState<Record<string, Attachment>>(
{},
);
const [deleteAttachments, setDeleteAttachments] = useState<string[]>([]);
const { data: attachmentData } = useQuery({
queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd],
queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd as string),
enabled: !!atflCd,
});
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 saveInterceptors = async (data: T) => {
if (deleteAttachments.length > 0) {
await Promise.all(
deleteAttachments.map(AttachmentApi.deleteAttachmentByAtflId),
);
}
handleSave(data);
};
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]);
useEffect(() => {
if (storedBbsInfo) setBbsInfo(JSON.parse(storedBbsInfo));
}, [storedBbsInfo]);
return (
<FormWrapper form={form} handleSave={handleSave}>
<FormWrapper form={form} handleSave={saveInterceptors}>
<div className="card">
<Table variant="secondary">
<colgroup>
@@ -265,14 +330,48 @@ function NoticeForm<T extends PostFormData | PostUpdateFormData>({
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"atflCd" as FieldPath<T>}
render={() => (
<FormItem>
<FormControl></FormControl>
</FormItem>
)}
<FileUploader
id="atflCd"
multiple
className="flex !h-auto max-w-full flex-row-reverse gap-4 [&>label]:h-[3.2rem]"
onUpload={(e) => {
const files = e.target.files;
if (!files) return;
for (const file of files) {
handleTusUpload(file, {
handleResponse: handleTusResponse,
});
}
}}
customPreview={
<div className="flex w-full gap-4">
{Object.entries(attachments).map(
([atflId, attachment]) => {
if (deleteAttachments.includes(atflId)) return null;
return (
<div
key={atflId}
className="flex h-[3.2rem] items-center gap-2 rounded-sm border border-border bg-background px-3 py-2 text-foreground"
>
<span>{attachment.orgnlFileNm}</span>
<Button
color="lightGray"
className="flex size-7 items-center justify-center rounded-full p-0"
onClick={() =>
setDeleteAttachments((prev) => [
...prev,
atflId,
])
}
>
<i className="diveicon di-x text-xs" />
</Button>
</div>
);
},
)}
</div>
}
/>
</TableCell>
</TableRow>
@@ -315,4 +414,4 @@ const PeriodSelector = ({ disabled }: { disabled: boolean }) => {
);
};
export { NoticeForm };
export { PostForm };