Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "warn",

"@typescript-eslint/no-explicit-any": "warn"
}
}
216 changes: 43 additions & 173 deletions app/entities/post/write/BlogForm.tsx
Original file line number Diff line number Diff line change
@@ -1,189 +1,53 @@
'use client';
import '@uiw/react-md-editor/markdown-editor.css';
import '@uiw/react-markdown-preview/markdown.css';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { PostBody } from '@/app/types/Post';
import { StaticImport } from 'next/dist/shared/lib/get-img-props';
import axios from 'axios';
import useToast from '@/app/hooks/useToast';
import { useBlockNavigate } from '@/app/hooks/common/useBlockNavigate';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import PostWriteButtons from '@/app/entities/post/write/PostWriteButtons';
import { validatePost } from '@/app/lib/utils/validate/validate';
import { Series } from '@/app/types/Series';
import Overlay from '@/app/entities/common/Overlay/Overlay';
import CreateSeriesOverlayContainer from '@/app/entities/series/CreateSeriesOverlayContainer';
import { getAllSeriesData } from '@/app/entities/series/api/series';
import UploadImageContainer from '@/app/entities/post/write/UploadImageContainer';
import useDraft from '@/app/hooks/post/useDraft';
import PostMetadataForm from '@/app/entities/post/write/PostMetadataForm';
import usePost from '@/app/hooks/post/usePost';

const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });

const BlogForm = () => {
const params = useSearchParams();
const slug = params.get('slug');
const [submitLoading, setSubmitLoading] = useState<boolean>(false);
const [title, setTitle] = useState('');
const [subTitle, setSubTitle] = useState('');
const [content, setContent] = useState<string | undefined>('');
const [profileImage, setProfileImage] = useState<string | StaticImport>();
const [thumbnailImage, setThumbnailImage] = useState<string | StaticImport>();
const [seriesList, setSeriesList] = useState<Series[]>([]);
const [seriesId, setSeriesId] = useState<string>();
const [seriesLoading, setSeriesLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [isPrivate, setIsPrivate] = useState<boolean>(false);

const toast = useToast();
const router = useRouter();
const NICKNAME = '개발자 서정우';
const [createSeriesOpen, setCreateSeriesOpen] = useState(false);
// 임시저장 상태
const { draft, draftImages, updateDraft, clearDraft } = useDraft();
// 이미지 상태
const [uploadedImages, setUploadedImages] = useState<string[]>([]);

const postBody: PostBody = {
const {
title,
subTitle,
author: NICKNAME,
content: content || '',
profileImage,
thumbnailImage,
seriesId: seriesId || '',
tags: tags,
isPrivate: isPrivate,
};
submitLoading,
seriesLoading,
seriesId,
seriesList,
content,
setTitle,
setSubTitle,
setContent,
setSeriesId,
setIsPrivate,
isPrivate,
tags,
setTags,
uploadedImages,
setUploadedImages,
overwriteDraft,
saveToDraft,
clearDraftInStore,
submitHandler,
postBody,
errors,
handleLinkCopy,
} = usePost(slug || '');

const [createSeriesOpen, setCreateSeriesOpen] = useState(false);
useBlockNavigate({ title, content: content || '' });

useEffect(() => {
getSeries();
}, []);

useEffect(() => {
if (slug) {
getPostDetail();
}
}, [slug]);

// 시리즈
const getSeries = async () => {
try {
const data = await getAllSeriesData();
setSeriesList(data);
setSeriesId(data[0]._id);
setSeriesLoading(false);
} catch (e) {
console.error('시리즈 조회 중 오류 발생', e);
}
};

// 블로그
const postBlog = async (post: PostBody) => {
try {
const response = await axios.post('/api/posts', post);
if (response.status === 201) {
toast.success('글이 성공적으로 발행되었습니다.');
router.push('/posts');
}
} catch (e) {
toast.error('글 발행 중 오류 발생했습니다.');
console.error('글 발행 중 오류 발생', e);
}
};

const updatePost = async (post: PostBody) => {
try {
const response = await axios.put(`/api/posts/${slug}`, post);
if (response.status === 200) {
toast.success('글이 성공적으로 수정되었습니다.');
router.push('/posts');
}
} catch (e) {
toast.error('글 수정 중 오류 발생했습니다.');
console.error('글 수정 중 오류 발생', e);
}
};

// 임시저장 관련 함수
const saveToDraft = () => {
const { success } = updateDraft(postBody, uploadedImages);
if (success) {
toast.success('임시 저장되었습니다.');
} else {
toast.error('임시 저장 실패');
}
};

const overwriteDraft = () => {
if (draft !== null) {
if (confirm('임시 저장된 글이 있습니다. 덮어쓰시겠습니까?')) {
const { title, content, subTitle, seriesId, isPrivate } = draft;
setTitle(title || '');
setContent(content);
setSubTitle(subTitle || '');
setSeriesId(seriesId);
setUploadedImages(draftImages || []);
setIsPrivate(isPrivate || false);
}
} else {
toast.error('임시 저장된 글이 없습니다.');
}
};

const clearDraftInStore = () => {
clearDraft();
toast.success('임시 저장이 삭제되었습니다.');
};

const submitHandler = (post: PostBody) => {
try {
setSubmitLoading(true);
const { isValid, errors } = validatePost(post);
setErrors(errors);
if (!isValid) {
toast.error('유효성 검사 실패');
console.error('유효성 검사 실패', errors);
setSubmitLoading(false);
return;
}

if (slug) {
updatePost(post);
} else {
postBlog(post);
}
clearDraft();
} catch (e) {
console.error('글 발행 중 오류 발생', e);
setSubmitLoading(false);
}
};

const getPostDetail = async () => {
try {
const response = await axios.get(`/api/posts/${slug}`);
const data = await response.data;
setTitle(data.post.title || '');
setSubTitle(data.post.subTitle);
setContent(data.post.content);
setSeriesId(data.post.seriesId || '');
setTags(data.post.tags || []);
setIsPrivate(data.post.isPrivate || false);
} catch (e) {
console.error('글 조회 중 오류 발생', e);
}
};

const handleLinkCopy = (image: string) => {
navigator.clipboard.writeText(image);
toast.success('이미지 링크가 복사되었습니다.');
};

return (
<div className={'px-16'}>
<PostMetadataForm
Expand Down Expand Up @@ -227,15 +91,7 @@ const BlogForm = () => {
setUploadedImages={setUploadedImages}
onClick={handleLinkCopy}
/>
{errors && (
<div className={'mt-2'}>
{errors.slice(0, 3).map((error, index) => (
<p key={index} className={'text-sm text-red-500'}>
{error}
</p>
))}
</div>
)}
<ErrorBox errors={errors} />
<PostWriteButtons
slug={slug}
postBody={postBody}
Expand All @@ -246,4 +102,18 @@ const BlogForm = () => {
</div>
);
};

const ErrorBox = ({ errors }: { errors: string[] | null }) => {
if (!errors) return null;

return (
<div className={'mt-2'}>
{errors.slice(0, 3).map((error, index) => (
<p key={index} className={'text-sm text-red-500'}>
{error}
</p>
))}
</div>
);
};
export default BlogForm;
32 changes: 22 additions & 10 deletions app/entities/post/write/UploadImageContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
import UploadedImage from '@/app/entities/post/write/UploadedImage';
import { FaImage } from 'react-icons/fa';
import { upload } from '@vercel/blob/client';
import { ChangeEvent, useState } from 'react';
import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';

interface UploadImageContainerProps {
onClick: (link: string) => void;
uploadedImages: string[];
setUploadedImages: (images: string[]) => void;
setUploadedImages: Dispatch<SetStateAction<string[]>>;
}
const UploadImageContainer = ({
onClick,
Expand All @@ -22,16 +22,28 @@ const UploadImageContainer = ({
throw new Error('이미지가 선택되지 않았습니다.');
}

const file = target.files[0];
const files = target.files;

const timestamp = new Date().getTime();
const pathname = `/images/${timestamp}-${file.name}`;
const newBlob = await upload(pathname, file, {
access: 'public',
handleUploadUrl: '/api/upload',
});
if (files.length === 0) {
throw new Error('업로드할 파일이 없습니다.');
}

for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith('image/')) {
throw new Error('이미지 파일만 업로드할 수 있습니다.');
}

const timestamp = new Date().getTime();
const pathname = `/images/${timestamp}-${file.name}`;
const newBlob = await upload(pathname, file, {
access: 'public',
handleUploadUrl: '/api/upload',
});

setUploadedImages((prev) => [...prev, newBlob.url]);
}

setUploadedImages([...uploadedImages, newBlob.url]);
return;
} catch (error) {
console.error('업로드 실패:', error);
Expand Down
36 changes: 36 additions & 0 deletions app/hooks/common/useURLSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

interface useURLSyncConfig {
baseURL: string;
params: Record<string, any> | Record<string, any[]>;
}

/**
* 서치파라미터를 쉽게 설정할 수 있는 훅을 만들어봅시다.
* example /posts?page=1&series=seriesSlug&query=query
* 필요한 파라미터는 baseURL, currentPage, seriesSlugParam, query입니다.
* @param baseURL
* @param params
*/

const useURLSync = ({ baseURL, params }: useURLSyncConfig) => {
const router = useRouter();

useEffect(() => {
const searchParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.set(key, String(value));
}
});
}
const finalUrl = `/${baseURL}?${searchParams.toString()}`;
router.push(finalUrl);
}, [...Object.values(params)]);

return {};
};

export default useURLSync;
Loading