diff --git a/src/features/draft/components/draft-side-bar.tsx/sidebar-header.tsx b/src/features/draft/components/draft-side-bar.tsx/sidebar-header.tsx
new file mode 100644
index 0000000..9b75c9b
--- /dev/null
+++ b/src/features/draft/components/draft-side-bar.tsx/sidebar-header.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import Image from 'next/image';
+
+import { SidebarHeader } from '@/components/ui/sidebar';
+
+import SideBarToggleButton from './sidebar-toggle-button';
+
+export default function SideBarHeader() {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/features/draft/components/draft-side-bar.tsx/sidebar-published-list.tsx b/src/features/draft/components/draft-side-bar.tsx/sidebar-published-list.tsx
new file mode 100644
index 0000000..d8d2dc6
--- /dev/null
+++ b/src/features/draft/components/draft-side-bar.tsx/sidebar-published-list.tsx
@@ -0,0 +1,12 @@
+import DraftListWrapper from './draft-list-wrapper';
+import SidebarDraftItem from './sidebar-draft-item';
+
+export default function SideBarPublishedList() {
+ return (
+
+
+
+ {' '}
+
+ );
+}
diff --git a/src/features/draft/components/draft-side-bar.tsx/sidebar-toggle-button.tsx b/src/features/draft/components/draft-side-bar.tsx/sidebar-toggle-button.tsx
new file mode 100644
index 0000000..e9a7798
--- /dev/null
+++ b/src/features/draft/components/draft-side-bar.tsx/sidebar-toggle-button.tsx
@@ -0,0 +1,13 @@
+import Image from 'next/image';
+
+import { useSidebar } from '@/components/ui/sidebar';
+
+export default function SideBarToggleButton() {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+}
diff --git a/src/features/draft/components/field-wrapper.tsx b/src/features/draft/components/field-wrapper.tsx
new file mode 100644
index 0000000..5b70826
--- /dev/null
+++ b/src/features/draft/components/field-wrapper.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+interface FieldWrapperProps {
+ label?: string;
+ children: React.ReactNode;
+ className?: string; // gap이나 스타일을 커스터마이징
+}
+
+export default function FieldWrapper({ label, children, className = '' }: FieldWrapperProps) {
+ return (
+
+ {label && {label}}
+ {children}
+
+ );
+}
diff --git a/src/features/draft/components/publish-bottom-sheet/publish-form.tsx b/src/features/draft/components/publish-bottom-sheet/publish-form.tsx
new file mode 100644
index 0000000..8b42948
--- /dev/null
+++ b/src/features/draft/components/publish-bottom-sheet/publish-form.tsx
@@ -0,0 +1,31 @@
+'use client';
+import { useState } from 'react';
+
+import PublishHeader from './publish-header';
+import PublishReserve from './publish-reserve';
+import PublishUrl from './publish-url';
+import SeriesSelect from './series-select';
+import FieldWrapper from '../field-wrapper';
+
+export default function PublishForm() {
+ const [selectedSeries, setSelectedSeries] = useState
('');
+
+ return (
+
+ );
+}
diff --git a/src/features/draft/components/publish-bottom-sheet/publish-header.tsx b/src/features/draft/components/publish-bottom-sheet/publish-header.tsx
new file mode 100644
index 0000000..8133b03
--- /dev/null
+++ b/src/features/draft/components/publish-bottom-sheet/publish-header.tsx
@@ -0,0 +1,26 @@
+'use client';
+import { Button } from '@/components/ui/button';
+import { DrawerClose, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
+
+export default function PublishHeader() {
+ return (
+
+
+ 게시하기
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/draft/components/publish-bottom-sheet/publish-reserve.tsx b/src/features/draft/components/publish-bottom-sheet/publish-reserve.tsx
new file mode 100644
index 0000000..89a55d0
--- /dev/null
+++ b/src/features/draft/components/publish-bottom-sheet/publish-reserve.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+export default function PublishReserve() {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/features/draft/components/publish-bottom-sheet/publish-trigger.tsx b/src/features/draft/components/publish-bottom-sheet/publish-trigger.tsx
new file mode 100644
index 0000000..4322aa1
--- /dev/null
+++ b/src/features/draft/components/publish-bottom-sheet/publish-trigger.tsx
@@ -0,0 +1,24 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
+
+import PublishForm from './publish-form';
+
+export default function PublishTrigger() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/draft/components/publish-bottom-sheet/publish-url.tsx b/src/features/draft/components/publish-bottom-sheet/publish-url.tsx
new file mode 100644
index 0000000..a5f74f0
--- /dev/null
+++ b/src/features/draft/components/publish-bottom-sheet/publish-url.tsx
@@ -0,0 +1,34 @@
+'use client';
+import { useEffect } from 'react';
+
+import { useDraftStore } from '@/store/useDraftStore';
+
+export default function PublishUrl() {
+ const { path, errors, setPath, initializeValidation } = useDraftStore();
+
+ useEffect(() => {
+ initializeValidation();
+ }, [initializeValidation]);
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setPath(e.target.value);
+ };
+
+ return (
+
+
+ {errors.path && (
+
+ {errors.path}
+
+ )}
+
+ );
+}
diff --git a/src/features/draft/components/publish-bottom-sheet/series-select.tsx b/src/features/draft/components/publish-bottom-sheet/series-select.tsx
new file mode 100644
index 0000000..d2fbeea
--- /dev/null
+++ b/src/features/draft/components/publish-bottom-sheet/series-select.tsx
@@ -0,0 +1,81 @@
+'use client';
+import { useState } from 'react';
+
+import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+
+interface Series {
+ value: string;
+}
+
+const seriesList: Series[] = [
+ {
+ value: '어디 출신',
+ },
+ {
+ value: '미시시피',
+ },
+ {
+ value: '부모님은',
+ },
+ {
+ value: '완전 부자',
+ },
+];
+
+interface SeriesSelectProps {
+ onSeriesChange?: (value: string) => void;
+ selectedSeries?: string;
+}
+
+export default function SeriesSelect({ onSeriesChange, selectedSeries }: SeriesSelectProps) {
+ const [open, setOpen] = useState(false);
+
+ const handleSelect = (currentValue: string) => {
+ setOpen(false);
+ onSeriesChange?.(currentValue);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {seriesList.map((series) => (
+
+ {series.value}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/features/draft/components/uploadoptionpopover.tsx b/src/features/draft/components/uploadoptionpopover.tsx
new file mode 100644
index 0000000..e8c0af7
--- /dev/null
+++ b/src/features/draft/components/uploadoptionpopover.tsx
@@ -0,0 +1,154 @@
+'use client';
+
+import Image from 'next/image';
+import React, { useState, useRef, useCallback, ChangeEvent } from 'react';
+
+import { PopoverContent } from '@/components/ui/popover';
+import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
+
+interface UploadOptionPopoverProps {
+ type: 'image' | 'file';
+ onFileUpload?: (files: File[]) => void;
+ onLinkSubmit?: (link: string) => void;
+}
+
+export default function UploadOptionPopover({
+ type,
+ onFileUpload,
+ onLinkSubmit,
+}: UploadOptionPopoverProps) {
+ const labels =
+ type === 'image'
+ ? { fileTab: '이미지', linkTab: '이미지 링크' }
+ : { fileTab: '업로드', linkTab: '링크 임베드' };
+
+ const [isDragging, setIsDragging] = useState(false);
+ const [linkValue, setLinkValue] = useState('');
+ const inputRef = useRef(null);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const hasFiles = Array.from(e.dataTransfer.items || []).some((item) => item.kind === 'file');
+ if (hasFiles) {
+ e.dataTransfer.dropEffect = 'copy';
+ setIsDragging(true);
+ } else {
+ e.dataTransfer.dropEffect = 'none';
+ }
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ const files = Array.from(e.dataTransfer.files || []);
+ if (files.length > 0 && onFileUpload) {
+ onFileUpload(files);
+ }
+ },
+ [onFileUpload],
+ );
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ const files = Array.from(e.target.files || []);
+ if (files.length > 0 && onFileUpload) {
+ onFileUpload(files);
+ }
+ },
+ [onFileUpload],
+ );
+
+ const handleLinkSubmit = useCallback(() => {
+ if (linkValue.trim() && onLinkSubmit) {
+ onLinkSubmit(linkValue);
+ setLinkValue('');
+ }
+ }, [linkValue, onLinkSubmit]);
+
+ return (
+
+
+
+
+ {labels.fileTab}
+
+
+ {labels.linkTab}
+
+
+
+
+
+
+ setLinkValue(e.target.value)}
+ placeholder='링크를 추가해주세요.'
+ className='h-[40px] w-[330px] rounded border px-4 py-[5px]'
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/draft/index.tsx b/src/features/draft/index.tsx
new file mode 100644
index 0000000..3783a74
--- /dev/null
+++ b/src/features/draft/index.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import DraftContents from './components/draft-contents';
+import DraftSideBar from './components/draft-side-bar.tsx';
+
+export default function Draft() {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/lib/editor/components/plate-editor.tsx b/src/lib/editor/components/plate-editor.tsx
index 1a45c8e..0f201ea 100644
--- a/src/lib/editor/components/plate-editor.tsx
+++ b/src/lib/editor/components/plate-editor.tsx
@@ -9,6 +9,7 @@ import { Editor, EditorContainer } from './ui/editor';
export function PlateEditor() {
const editor = usePlateEditor({
plugins: EditorKit,
+ value: [{ type: 'p', children: [{ text: '' }] }],
});
return (
diff --git a/src/lib/editor/components/ui/editor.tsx b/src/lib/editor/components/ui/editor.tsx
index 15e6056..e67dd26 100644
--- a/src/lib/editor/components/ui/editor.tsx
+++ b/src/lib/editor/components/ui/editor.tsx
@@ -56,7 +56,7 @@ const editorVariants = cva(
'group/editor',
'relative w-full cursor-text overflow-x-hidden break-words whitespace-pre-wrap select-text',
'rounded-md ring-offset-background focus-visible:outline-none',
- 'placeholder:text-muted-foreground/80 **:data-slate-placeholder:!top-1/2 **:data-slate-placeholder:-translate-y-1/2 **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!',
+ 'placeholder:text-muted-foreground/80 **:data-slate-placeholder:!top-7 **:data-slate-placeholder:-translate-y-1/2 **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!',
'[&_strong]:font-bold',
),
{
@@ -72,7 +72,7 @@ const editorVariants = cva(
},
variant: {
comment: cn('rounded-none border-none bg-transparent text-sm'),
- default: 'size-full py-15 px-40 text-base',
+ default: 'size-full py-15 px-25 text-base',
fullWidth: 'size-full px-16 pt-4 pb-72 text-base sm:px-24',
none: '',
select: 'px-3 py-2 text-base data-readonly:w-fit',
diff --git a/src/lib/utils/createZodSchemaFromApi.ts b/src/lib/utils/createZodSchemaFromApi.ts
new file mode 100644
index 0000000..6a730a0
--- /dev/null
+++ b/src/lib/utils/createZodSchemaFromApi.ts
@@ -0,0 +1,58 @@
+import { z } from 'zod';
+
+type ValidationRule = {
+ type: string;
+ required?: boolean;
+ minLength?: number;
+ maxLength?: number;
+ regexp?: {
+ pattern: string;
+ flags?: string;
+ };
+ messages?: Record;
+};
+
+export type ApiValidations = {
+ validations: Record;
+};
+
+export function createZodSchemaFromApi(apiValidations: ApiValidations) {
+ const schemaShape: Record = {};
+
+ Object.entries(apiValidations.validations).forEach(([fieldName, rules]) => {
+ let fieldSchema: z.ZodTypeAny;
+
+ if (rules.type === 'string') {
+ fieldSchema = z.string({
+ message: rules.messages?.required,
+ });
+
+ if (rules.minLength) {
+ fieldSchema = (fieldSchema as z.ZodString).min(rules.minLength, {
+ message: rules.messages?.minLength,
+ });
+ }
+
+ if (rules.maxLength) {
+ fieldSchema = (fieldSchema as z.ZodString).max(rules.maxLength, {
+ message: rules.messages?.maxLength,
+ });
+ }
+
+ if (rules.regexp) {
+ const regex = new RegExp(rules.regexp.pattern, rules.regexp.flags);
+ fieldSchema = (fieldSchema as z.ZodString).regex(regex, {
+ message: rules.messages?.regexp,
+ });
+ }
+
+ if (!rules.required) {
+ fieldSchema = fieldSchema.optional();
+ }
+ }
+
+ schemaShape[fieldName] = fieldSchema!;
+ });
+
+ return z.object(schemaShape);
+}
diff --git a/src/sevices/getValidationSchema.ts b/src/sevices/getValidationSchema.ts
new file mode 100644
index 0000000..54a8643
--- /dev/null
+++ b/src/sevices/getValidationSchema.ts
@@ -0,0 +1,21 @@
+import z from 'zod';
+
+import { createZodSchemaFromApi } from '@/lib/utils/createZodSchemaFromApi';
+
+export const getValidationSchema = async (): Promise
+> | null> => {
+ try {
+ const response = await fetch('http://localhost:8080/api/validations/draft?context=patch');
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+ const data = await response.json();
+
+ const schema = createZodSchemaFromApi(data); // ✅ JSON → Zod 스키마 변환
+ return schema;
+ } catch (error) {
+ console.error('Failed to fetch validation rules:', error);
+ return null;
+ }
+};
diff --git a/src/store/useDraftStore.ts b/src/store/useDraftStore.ts
new file mode 100644
index 0000000..6c85ede
--- /dev/null
+++ b/src/store/useDraftStore.ts
@@ -0,0 +1,161 @@
+// store/useFormStore.ts
+import { z } from 'zod';
+import { create } from 'zustand';
+
+import { getValidationSchema } from '@/sevices/getValidationSchema';
+
+type FormStore = {
+ // 상태
+ path: string;
+ title: string;
+ entryBlockId: string;
+ blogId: string;
+
+ // 검증 스키마 (동적으로 로드)
+ validationSchema: z.ZodObject> | null;
+ // 검증 에러
+ errors: Record;
+ isValidating: boolean;
+
+ // 액션
+ setPath: (path: string) => void;
+ setTitle: (title: string) => void;
+ setEntryBlockId: (entryBlockId: string) => void;
+ setBlogId: (blogId: string) => void;
+
+ // 검증 관련
+ initializeValidation: () => Promise;
+ validate: () => Promise<{ success: boolean; errors?: Record }>;
+ validateField: (fieldName: string, value?: string) => Promise;
+ clearErrors: () => void;
+};
+
+export const useDraftStore = create((set, get) => ({
+ // 초기 상태
+ path: '',
+ title: '',
+ entryBlockId: '',
+ blogId: '',
+ validationSchema: null,
+ errors: {},
+ isValidating: false,
+
+ // 액션
+ setPath: (path) => {
+ set({ path });
+ // 실시간 검증 (선택사항)
+ get().validateField('path', path);
+ },
+
+ setTitle: (title) => {
+ set({ title });
+ get().validateField('title', title);
+ },
+
+ setEntryBlockId: (entryBlockId) => {
+ set({ entryBlockId });
+ get().validateField('entryBlockId', entryBlockId);
+ },
+
+ setBlogId: (blogId) => {
+ set({ blogId });
+ get().validateField('blogId', blogId);
+ },
+
+ // 검증 스키마 초기화
+ initializeValidation: async () => {
+ const schema = await getValidationSchema();
+ set({ validationSchema: schema });
+ },
+
+ // 전체 폼 검증
+ validate: async () => {
+ const { path, title, entryBlockId, blogId, validationSchema } = get();
+
+ if (!validationSchema) {
+ await get().initializeValidation();
+ }
+
+ const schema = get().validationSchema;
+
+ if (!schema) {
+ return { success: false, errors: { _form: '검증 스키마를 불러올 수 없습니다' } };
+ }
+
+ set({ isValidating: true });
+
+ try {
+ schema.parse({
+ path,
+ title,
+ entryBlockId,
+ blogId,
+ });
+
+ set({ errors: {}, isValidating: false });
+ return { success: true };
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ const errors: Record = {};
+ error.issues.forEach((err) => {
+ const fieldName = err.path[0] as string;
+ errors[fieldName] = err.message;
+ });
+
+ set({ errors, isValidating: false });
+ return { success: false, errors };
+ }
+
+ set({ isValidating: false });
+ return { success: false, errors: { _form: '알 수 없는 오류가 발생했습니다' } };
+ }
+ },
+
+ // 개별 필드 검증
+ validateField: async (fieldName: string, value?: string) => {
+ const state = get();
+ const schema = state.validationSchema;
+
+ if (!schema) {
+ return null;
+ }
+
+ try {
+ // value가 제공되면 사용, 아니면 현재 상태에서 가져오기
+ const fieldValue = value;
+
+ // 개별 필드만 검증
+ const fieldSchema = schema.shape[fieldName];
+
+ if (!fieldSchema) {
+ console.warn(`⚠️ No schema found for field: ${fieldName}`);
+ return null;
+ }
+
+ fieldSchema.parse(fieldValue);
+
+ // 해당 필드의 에러 제거
+ const newErrors = { ...state.errors };
+ delete newErrors[fieldName];
+ set({ errors: newErrors });
+
+ return null;
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ const errorMessage = error.issues[0]?.message || '유효하지 않은 값입니다';
+
+ set({
+ errors: {
+ ...state.errors,
+ [fieldName]: errorMessage,
+ },
+ });
+ return errorMessage;
+ }
+ }
+
+ return null;
+ },
+
+ clearErrors: () => set({ errors: {} }),
+}));