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
25 changes: 21 additions & 4 deletions app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { getServerSession } from 'next-auth';
import { isAdminSession } from '@/app/lib/authz';
import dbConnect from '@/app/lib/dbConnect';
import { getThumbnailInMarkdown } from '@/app/lib/utils/parse';
import { generateUniqueSlug } from '@/app/lib/utils/post';
import Post from '@/app/models/Post';
import Series from '@/app/models/Series';

Expand Down Expand Up @@ -123,6 +122,7 @@ export async function POST(req: Request) {

await dbConnect();
const {
slug,
title,
subTitle,
author,
Expand All @@ -134,13 +134,30 @@ export async function POST(req: Request) {
sendToSubscribers,
} = await req.json();

if (!title || !content || !author || !content) {
if (!title || !content || !author || !slug) {
return Response.json(
{ success: false, error: '제목, 소제목, 작성자, 내용은 필수입니다' },
{ success: false, error: '제목, 작성자, 내용, 슬러그는 필수입니다' },
{ status: 400 }
);
}

// slug 패턴 검사
if (!/^[a-zA-Z0-9-]+$/.test(slug)) {
return Response.json(
{ success: false, error: 'slug는 영문, 숫자, 하이픈(-)만 허용됩니다' },
{ status: 400 }
);
}

// slug 중복 검사
const existingPost = await Post.findOne({ slug });
if (existingPost) {
return Response.json(
{ success: false, error: '이미 사용 중인 slug입니다. 다른 slug를 입력해주세요.' },
{ status: 409 }
);
}

let thumbnailOfPost = getThumbnailInMarkdown(content);
if (!thumbnailOfPost) {
const defaultSeriesThumbnail =
Expand All @@ -151,7 +168,7 @@ export async function POST(req: Request) {
}

const post = {
slug: await generateUniqueSlug(title, Post),
slug,
title,
subTitle,
author,
Expand Down
115 changes: 63 additions & 52 deletions app/entities/post/write/BlogForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import '@uiw/react-md-editor/markdown-editor.css';
import '@uiw/react-markdown-preview/markdown.css';
import dynamic from 'next/dynamic';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import ImageZoomViewer from '@/app/entities/common/Overlay/Image/ImageZoomViewer';
import Overlay from '@/app/entities/common/Overlay/Overlay';
import Callout from '@/app/entities/post/detail/Callout';
Expand Down Expand Up @@ -44,6 +44,20 @@ const calloutCommand: ICommand = {
},
};

const editorExtraCommands = [
calloutCommand,
commands.divider,
...commands.getExtraCommands(),
];

const CalloutComponent = ({
emoji,
children,
}: {
emoji?: string;
children?: React.ReactNode;
}) => <Callout emoji={emoji}>{children}</Callout>;

export interface SelectedImage {
src: string;
alt?: string;
Expand Down Expand Up @@ -115,17 +129,46 @@ const BlogForm = () => {

useBlockNavigate({ title: formData.title, content: formData.content || '' });

// 이미지 클릭 핸들러 생성
const addImageClickHandler = createImageClickHandler(
setSelectedImage
const addImageClickHandler = useMemo(
() => createImageClickHandler(setSelectedImage),
[]
);

const handleFieldChange = (
const handleContentChange = useCallback((value: string | undefined) => {
setFormData({ content: value });
}, [setFormData]);

const editorPreviewOptions = useMemo(() => ({
wrapperElement: { 'data-color-mode': theme },
components: { callout: CalloutComponent } as any,
rehypeRewrite: (
node: Root | RootContent,
index?: number,
parent?: Root | HastElement
) => {
asideToCallout(node);
renderYoutubeEmbed(node, index || 0, parent as HastElement | undefined);
addImageClickHandler(node);
addDescriptionUnderImage(node, index, parent as HastElement | undefined);
},
}), [theme, addImageClickHandler]);

const handleFieldChange = useCallback((
field: string,
value: string | boolean | string[]
) => {
setFormData({ [field]: value });
};
}, [setFormData]);

const metadataFormData = useMemo(() => ({
title: formData.title,
subTitle: formData.subTitle,
slug: formData.slug,
seriesId: formData.seriesId,
tags: formData.tags,
isPrivate: formData.isPrivate,
sendToSubscribers: formData.sendToSubscribers,
}), [formData.title, formData.subTitle, formData.slug, formData.seriesId, formData.tags, formData.isPrivate, formData.sendToSubscribers]);

// 로컬 + 클라우드 임시저장본 병합
const getAllDrafts = (): DraftListItem[] => {
Expand Down Expand Up @@ -185,6 +228,7 @@ const BlogForm = () => {
setFormData({
title: localData.title || '',
subTitle: localData.subTitle || '',
slug: '',
content: localData.content,
seriesId: localData.seriesId || '',
tags: localData.tags || [],
Expand All @@ -196,6 +240,7 @@ const BlogForm = () => {
setFormData({
title: cloudData.title || '',
subTitle: cloudData.subTitle || '',
slug: '',
content: cloudData.content,
seriesId: cloudData.seriesId || '',
tags: cloudData.tags || [],
Expand Down Expand Up @@ -225,33 +270,34 @@ const BlogForm = () => {
}
};

// 임시저장본 불러오기 오버레이 열기
const openLoadDraftOverlay = () => {
const handleOpenCreateSeries = useCallback(() => setCreateSeriesOpen(true), []);

const openLoadDraftOverlay = useCallback(() => {
setDraftListMode('load');
setDraftListOpen(true);
};
}, []);

// 임시저장 삭제 오버레이 열기
const openDeleteDraftOverlay = () => {
const openDeleteDraftOverlay = useCallback(() => {
setDraftListMode('delete');
setDraftListOpen(true);
};
}, []);

return (
<div className={'px-2'}>
<h1 className={'text-2xl text-center mb-4'}>
글 {slug ? '수정' : '작성'}
</h1>
<PostMetadataForm
formData={formData}
formData={metadataFormData}
onFieldChange={handleFieldChange}
seriesLoading={uiState.seriesLoading}
series={seriesList}
onClickNewSeries={() => setCreateSeriesOpen(true)}
onClickNewSeries={handleOpenCreateSeries}
onClickOverwrite={openLoadDraftOverlay}
clearDraft={openDeleteDraftOverlay}
autoSyncEnabled={autoSyncEnabled}
onToggleAutoSync={toggleAutoSync}
isEditMode={isEditMode}
/>
<Overlay
overlayOpen={createSeriesOpen}
Expand Down Expand Up @@ -283,48 +329,13 @@ const BlogForm = () => {
<div ref={containerRef}>
<MDEditor
value={formData.content}
onChange={(value) => setFormData({ content: value })}
extraCommands={[
calloutCommand,
commands.divider,
...commands.getExtraCommands(),
]}
onChange={handleContentChange}
extraCommands={editorExtraCommands}
height={500}
minHeight={500}
visibleDragbar={false}
data-color-mode={theme}
previewOptions={{
wrapperElement: {
'data-color-mode': theme,
},
components: {
callout: ({
emoji,
children,
}: {
emoji?: string;
children?: React.ReactNode;
}) => <Callout emoji={emoji}>{children}</Callout>,
} as any,
rehypeRewrite: (
node: Root | RootContent,
index?: number,
parent?: Root | HastElement
) => {
asideToCallout(node);
renderYoutubeEmbed(
node,
index || 0,
parent as HastElement | undefined
);
addImageClickHandler(node);
addDescriptionUnderImage(
node,
index,
parent as HastElement | undefined
);
},
}}
previewOptions={editorPreviewOptions}
/>
</div>
<UploadImageContainer
Expand Down
68 changes: 61 additions & 7 deletions app/entities/post/write/PostMetadataForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeEvent, useState } from 'react';
import { ChangeEvent, memo, useState } from 'react';
import { CgMoveRight } from 'react-icons/cg';
import { FaTrash } from 'react-icons/fa';
import { FaPlus } from 'react-icons/fa6';
Expand All @@ -17,9 +17,11 @@ interface PostMetadataFormProps {
clearDraft: () => void;
autoSyncEnabled: boolean;
onToggleAutoSync: (enabled: boolean) => void;
isEditMode?: boolean;
formData: {
title: string;
subTitle: string;
slug: string;
seriesId?: string;
tags: string[];
isPrivate: boolean;
Expand All @@ -36,15 +38,43 @@ const PostMetadataForm = ({
clearDraft,
autoSyncEnabled,
onToggleAutoSync,
isEditMode = false,
formData,
}: PostMetadataFormProps) => {
const [tagInput, setTagInput] = useState<string>('');
const [slugError, setSlugError] = useState<string>('');

const { title, subTitle, seriesId, tags, isPrivate, sendToSubscribers } =
formData;
const handleSlugChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (/[가-힣]/.test(value)) {
setSlugError(
'한글은 입력할 수 없습니다. 영문, 숫자, 하이픈(-)만 사용하세요.'
);
} else if (value && !/^[a-zA-Z0-9-]+$/.test(value)) {
setSlugError('영문, 숫자, 하이픈(-)만 사용할 수 있습니다.');
} else {
setSlugError('');
}
onFieldChange('slug', value);
};

const { suggestions, isOpen, highlightedIndex, setIsOpen, setHighlightedIndex } =
useTagAutocomplete({ tagInput, currentTags: tags });
const {
title,
subTitle,
slug,
seriesId,
tags,
isPrivate,
sendToSubscribers,
} = formData;

const {
suggestions,
isOpen,
highlightedIndex,
setIsOpen,
setHighlightedIndex,
} = useTagAutocomplete({ tagInput, currentTags: tags });
const selectOptions = series.map((s) => ({
value: s._id,
label: s.title,
Expand Down Expand Up @@ -77,7 +107,10 @@ const PostMetadataForm = ({
};

const handleTagRemove = (index: number) => {
onFieldChange('tags', tags.filter((_, i) => i !== index));
onFieldChange(
'tags',
tags.filter((_, i) => i !== index)
);
};

const handleSelectSuggestion = (tag: string) => {
Expand Down Expand Up @@ -165,6 +198,27 @@ const PostMetadataForm = ({
value={subTitle}
/>
</div>
<div className="flex mb-4 gap-1 items-center">
<span className="font-bold text-default flex-shrink-0">
슬&nbsp;&nbsp;러&nbsp;&nbsp;그&nbsp;
</span>
<input
type="text"
placeholder={
isEditMode ? '' : '영문, 숫자, 하이픈(-)만 입력 (예: my-post-title)'
}
className={`inline min-w-12 px-2 py-1 outline-none text-default bg-transparent border-b text-sm flex-grow ${
slugError ? 'border-red-500' : 'border-gray-300'
} ${isEditMode ? 'opacity-60 cursor-not-allowed' : ''}`}
onChange={handleSlugChange}
value={slug}
disabled={isEditMode}
required
/>
</div>
{slugError && (
<p className="text-xs text-red-500 mb-3 ml-16">{slugError}</p>
)}

<div className={'flex justify-start items-center'}>
<div className="flex flex-wrap mb-4 gap-1 items-center">
Expand Down Expand Up @@ -280,4 +334,4 @@ const PostMetadataForm = ({
);
};

export default PostMetadataForm;
export default memo(PostMetadataForm);
6 changes: 3 additions & 3 deletions app/hooks/post/useCloudDraft.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import axios from 'axios';
import { useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { CloudDraft } from '@/app/types/Draft';

Expand Down Expand Up @@ -61,10 +61,10 @@ const useCloudDraft = () => {
}
};

const toggleAutoSync = (enabled: boolean) => {
const toggleAutoSync = useCallback((enabled: boolean) => {
setAutoSyncEnabled(enabled);
localStorage.setItem('cloudDraftAutoSync', enabled.toString());
};
}, []);

const getCurrentDraftId = () => draftIdRef.current;

Expand Down
Loading
Loading