From 401c3971155a6d862e58ee3b9c3b99524ea4e2ad Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Fri, 5 Dec 2025 11:08:19 +0900 Subject: [PATCH 01/12] =?UTF-8?q?chore:=20uuid=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/package.json b/package.json index 7d905aa..11183d3 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "react-lottie-player": "^2.1.0", "resend": "^6.5.2", "sharp": "^0.33.5", + "uuid": "^13.0.0", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ae0005..e5edb6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: sharp: specifier: ^0.33.5 version: 0.33.5 + uuid: + specifier: ^13.0.0 + version: 13.0.0 zustand: specifier: ^5.0.3 version: 5.0.3(@types/react@18.3.20)(react@18.3.1) @@ -3701,6 +3704,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -8471,6 +8478,8 @@ snapshots: uuid@10.0.0: {} + uuid@13.0.0: {} + uuid@8.3.2: {} v8-compile-cache-lib@3.0.1: {} From 4d224dd7b8411b185a1ced1a9d602a4ebf8f5526 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Fri, 5 Dec 2025 11:09:20 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EC=9E=84=EC=8B=9C=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20API=20route=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/drafts/route.ts | 166 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 app/api/drafts/route.ts diff --git a/app/api/drafts/route.ts b/app/api/drafts/route.ts new file mode 100644 index 0000000..0bf0cfb --- /dev/null +++ b/app/api/drafts/route.ts @@ -0,0 +1,166 @@ +import { getServerSession } from 'next-auth'; +import dbConnect from '@/app/lib/dbConnect'; +import CloudDraft from '@/app/models/CloudDraft'; + +// GET /api/drafts - 사용자의 클라우드 임시저장본 조회 +export async function GET(req: Request) { + try { + const session = await getServerSession(); + if (!session?.user?.email) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + await dbConnect(); + + // 30일 이상 지난 임시저장본 자동 삭제 + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + await CloudDraft.deleteMany({ + createdAt: { $lt: thirtyDaysAgo }, + }); + + // 사용자의 임시저장본 조회 (최신순, 최대 3개) + const drafts = await CloudDraft.find({ userId: session.user.email }) + .sort({ createdAt: -1 }) + .limit(3) + .lean(); + + return Response.json({ success: true, drafts }, { status: 200 }); + } catch (error) { + console.error('Cloud draft fetch error:', error); + return Response.json( + { error: 'Failed to fetch drafts' }, + { status: 500 } + ); + } +} + +// POST /api/drafts - 클라우드 임시저장본 생성 또는 업데이트 +export async function POST(req: Request) { + try { + const session = await getServerSession(); + if (!session?.user?.email) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + await dbConnect(); + + const { + draftId, + title, + subTitle, + content, + tags, + imageUrls, + seriesId, + isPrivate, + } = await req.json(); + + if (!draftId) { + return Response.json({ error: 'draftId required' }, { status: 400 }); + } + + // 최소 검증: title 또는 content 필수 + if (!title && !content) { + return Response.json( + { error: 'Draft must have title or content' }, + { status: 400 } + ); + } + + const userId = session.user.email; + + // 기존 임시저장본 확인 + const existingDraft = await CloudDraft.findOne({ draftId, userId }); + + if (existingDraft) { + // 기존 임시저장본 업데이트 + const updatedDraft = await CloudDraft.findOneAndUpdate( + { draftId, userId }, + { + title, + subTitle, + content, + tags, + imageUrls, + seriesId, + isPrivate, + }, + { new: true, runValidators: true } + ); + + return Response.json( + { success: true, draft: updatedDraft }, + { status: 200 } + ); + } else { + // 새 임시저장본 생성 + // 3개 제한 확인 + const draftCount = await CloudDraft.countDocuments({ userId }); + + if (draftCount >= 3) { + // 가장 오래된 임시저장본 삭제 + const oldestDraft = await CloudDraft.findOne({ userId }) + .sort({ createdAt: 1 }) + .lean(); + + if (oldestDraft) { + await CloudDraft.deleteOne({ _id: oldestDraft._id }); + } + } + + // 새 임시저장본 생성 + const newDraft = await CloudDraft.create({ + draftId, + userId, + title, + subTitle, + content, + tags, + imageUrls, + seriesId, + isPrivate, + }); + + return Response.json( + { success: true, draft: newDraft }, + { status: 201 } + ); + } + } catch (error) { + console.error('Cloud draft save error:', error); + return Response.json({ error: 'Failed to save draft' }, { status: 500 }); + } +} + +// DELETE /api/drafts?draftId=xxx - 특정 클라우드 임시저장본 삭제 +export async function DELETE(req: Request) { + try { + const session = await getServerSession(); + if (!session?.user?.email) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + await dbConnect(); + + const { searchParams } = new URL(req.url); + const draftId = searchParams.get('draftId'); + + if (!draftId) { + return Response.json({ error: 'draftId required' }, { status: 400 }); + } + + const result = await CloudDraft.deleteOne({ + draftId, + userId: session.user.email, + }); + + if (result.deletedCount === 0) { + return Response.json({ error: 'Draft not found' }, { status: 404 }); + } + + return Response.json({ success: true }, { status: 200 }); + } catch (error) { + console.error('Cloud draft delete error:', error); + return Response.json({ error: 'Failed to delete draft' }, { status: 500 }); + } +} From 1117ac092f4633751945f98f72e47544d4d26ce4 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Fri, 5 Dec 2025 11:10:25 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20=ED=83=80=EC=9E=85=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/drafts/route.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/api/drafts/route.ts b/app/api/drafts/route.ts index 0bf0cfb..82121f1 100644 --- a/app/api/drafts/route.ts +++ b/app/api/drafts/route.ts @@ -12,7 +12,7 @@ export async function GET(req: Request) { await dbConnect(); - // 30일 이상 지난 임시저장본 자동 삭제 + // 30일 이상 지난 임시저장본이 있다면 삭제 const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); await CloudDraft.deleteMany({ createdAt: { $lt: thirtyDaysAgo }, @@ -27,10 +27,7 @@ export async function GET(req: Request) { return Response.json({ success: true, drafts }, { status: 200 }); } catch (error) { console.error('Cloud draft fetch error:', error); - return Response.json( - { error: 'Failed to fetch drafts' }, - { status: 500 } - ); + return Response.json({ error: 'Failed to fetch drafts' }, { status: 500 }); } } @@ -103,7 +100,7 @@ export async function POST(req: Request) { .sort({ createdAt: 1 }) .lean(); - if (oldestDraft) { + if (oldestDraft && !Array.isArray(oldestDraft)) { await CloudDraft.deleteOne({ _id: oldestDraft._id }); } } @@ -121,10 +118,7 @@ export async function POST(req: Request) { isPrivate, }); - return Response.json( - { success: true, draft: newDraft }, - { status: 201 } - ); + return Response.json({ success: true, draft: newDraft }, { status: 201 }); } } catch (error) { console.error('Cloud draft save error:', error); From 151392d207d0b5ff8efc97b23163a5350f78de98 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Fri, 5 Dec 2025 11:11:56 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=EC=9E=84=EC=8B=9C=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=93=9C=EB=9E=98=ED=94=84=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=B0=8F=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/CloudDraft.ts | 56 ++++++++++++++++++++++++++++++++++++++++ app/types/Draft.ts | 31 ++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 app/models/CloudDraft.ts create mode 100644 app/types/Draft.ts diff --git a/app/models/CloudDraft.ts b/app/models/CloudDraft.ts new file mode 100644 index 0000000..ae79faf --- /dev/null +++ b/app/models/CloudDraft.ts @@ -0,0 +1,56 @@ +import { Schema, model, models } from 'mongoose'; + +const cloudDraftSchema = new Schema( + { + draftId: { + type: String, + required: true, + unique: true, + index: true, + }, + userId: { + type: String, + required: true, + index: true, + }, + title: { + type: String, + default: '', + }, + subTitle: { + type: String, + default: '', + }, + content: { + type: String, + default: '', + }, + tags: { + type: [String], + default: [], + }, + imageUrls: { + type: [String], + default: [], + }, + seriesId: { + type: String, + default: '', + }, + isPrivate: { + type: Boolean, + default: false, + }, + }, + { + timestamps: true, // createdAt, updatedAt 자동 생성 + } +); + +// 효율적인 쿼리를 위한 복합 인덱스 +cloudDraftSchema.index({ userId: 1, createdAt: -1 }); + +const CloudDraft = + models.CloudDraft || model('CloudDraft', cloudDraftSchema); + +export default CloudDraft; diff --git a/app/types/Draft.ts b/app/types/Draft.ts new file mode 100644 index 0000000..2adb01f --- /dev/null +++ b/app/types/Draft.ts @@ -0,0 +1,31 @@ +export interface CloudDraft { + _id: string; + draftId: string; + userId: string; + title: string; + subTitle: string; + content: string; + tags: string[]; + imageUrls: string[]; + seriesId?: string; + isPrivate: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface LocalDraft { + title: string; + subTitle: string; + content?: string; + tags: string[]; + seriesId?: string; + isPrivate: boolean; +} + +export interface DraftListItem { + id: string; // draftId for cloud, 'local' for local + title: string; + date: Date; + source: 'local' | 'cloud'; + data: LocalDraft | CloudDraft; +} From c61900b677dd5c5117e6b219255fbabd2139e2a9 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Fri, 5 Dec 2025 11:12:14 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EB=B2=84=ED=8A=BC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/entities/post/write/AutoSyncToggle.tsx | 25 +++ app/entities/post/write/BlogForm.tsx | 171 ++++++++++++++++++- app/entities/post/write/DraftListOverlay.tsx | 104 +++++++++++ app/entities/post/write/PostMetadataForm.tsx | 9 + app/entities/post/write/PostWriteButtons.tsx | 8 + 5 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 app/entities/post/write/AutoSyncToggle.tsx create mode 100644 app/entities/post/write/DraftListOverlay.tsx diff --git a/app/entities/post/write/AutoSyncToggle.tsx b/app/entities/post/write/AutoSyncToggle.tsx new file mode 100644 index 0000000..73099a8 --- /dev/null +++ b/app/entities/post/write/AutoSyncToggle.tsx @@ -0,0 +1,25 @@ +interface AutoSyncToggleProps { + enabled: boolean; + onToggle: (enabled: boolean) => void; +} + +const AutoSyncToggle = ({ enabled, onToggle }: AutoSyncToggleProps) => { + return ( + + ); +}; + +export default AutoSyncToggle; diff --git a/app/entities/post/write/BlogForm.tsx b/app/entities/post/write/BlogForm.tsx index d290dc7..287a2db 100644 --- a/app/entities/post/write/BlogForm.tsx +++ b/app/entities/post/write/BlogForm.tsx @@ -3,23 +3,29 @@ 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 { useState } from 'react'; +import { useEffect, useState } from 'react'; import ImageZoomOverlayContainer from '@/app/entities/common/Overlay/Image/ImageZoomOverlayContainer'; import Overlay from '@/app/entities/common/Overlay/Overlay'; import PostMetadataForm from '@/app/entities/post/write/PostMetadataForm'; import PostWriteButtons from '@/app/entities/post/write/PostWriteButtons'; import UploadImageContainer from '@/app/entities/post/write/UploadImageContainer'; +import DraftListOverlay from '@/app/entities/post/write/DraftListOverlay'; import CreateSeriesOverlayContainer from '@/app/entities/series/CreateSeriesOverlayContainer'; import { useBlockNavigate } from '@/app/hooks/common/useBlockNavigate'; import useOverlay from '@/app/hooks/common/useOverlay'; import usePost from '@/app/hooks/post/usePost'; +import useCloudDraft from '@/app/hooks/post/useCloudDraft'; +import useAutoSync from '@/app/hooks/post/useAutoSync'; +import useDraft from '@/app/hooks/post/useDraft'; import useTheme from '@/app/hooks/useTheme'; +import useToast from '@/app/hooks/useToast'; import { asideStyleRewrite, addDescriptionUnderImage, renderYoutubeEmbed, createImageClickHandler, } from '@/app/lib/utils/rehypeUtils'; +import { CloudDraft, DraftListItem, LocalDraft } from '@/app/types/Draft'; import LoadingSpinner from '../../common/Loading/LoadingSpinner'; const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }); @@ -34,6 +40,7 @@ const BlogForm = () => { const slug = params.get('slug'); const isEditMode = Boolean(slug); const { theme } = useTheme(); + const toast = useToast(); const { formData, @@ -42,20 +49,49 @@ const BlogForm = () => { seriesList, uploadedImages, setUploadedImages, - overwriteDraft, saveToDraft, - clearDraftInStore, submitHandler, postBody, handleLinkCopy, } = usePost(slug || ''); + // 로컬 임시저장 훅 + const { draft, draftImages, clearDraft } = useDraft(); + + // 클라우드 임시저장 훅 + const { + cloudDrafts, + loading: cloudLoading, + autoSyncEnabled, + fetchCloudDrafts, + saveToCloud, + deleteCloudDraft, + toggleAutoSync, + } = useCloudDraft(); + const [createSeriesOpen, setCreateSeriesOpen] = useState(false); + const [draftListOpen, setDraftListOpen] = useState(false); + const [draftListMode, setDraftListMode] = useState<'load' | 'delete'>('load'); const [selectedImage, setSelectedImage] = useState( null ); const { isOpen: openImageBox, setIsOpen: setOpenImageBox } = useOverlay(); + // 클라우드 임시저장본 조회 + useEffect(() => { + fetchCloudDrafts().catch((error) => { + console.error('Failed to fetch cloud drafts:', error); + }); + }, []); + + // 자동 동기화 설정 + useAutoSync({ + enabled: autoSyncEnabled, + intervalMs: 180000, // 3분 + onSync: handleSaveToCloud, + deps: [formData.title, formData.content, formData.subTitle, formData.tags], + }); + useBlockNavigate({ title: formData.title, content: formData.content || '' }); // 이미지 클릭 핸들러 생성 @@ -71,6 +107,116 @@ const BlogForm = () => { setFormData({ [field]: value }); }; + // 로컬 + 클라우드 임시저장본 병합 + const getAllDrafts = (): DraftListItem[] => { + const drafts: DraftListItem[] = []; + + // 로컬 임시저장본 추가 + if (draft) { + drafts.push({ + id: 'local', + title: draft.title || '', + date: new Date(), // 로컬은 타임스탬프가 없음 + source: 'local', + data: draft as LocalDraft, + }); + } + + // 클라우드 임시저장본 추가 + cloudDrafts.forEach((cd) => { + drafts.push({ + id: cd.draftId, + title: cd.title, + date: new Date(cd.updatedAt), + source: 'cloud', + data: cd as CloudDraft, + }); + }); + + // 최신순 정렬 + return drafts.sort((a, b) => b.date.getTime() - a.date.getTime()); + }; + + // 클라우드에 저장 + async function handleSaveToCloud() { + try { + await saveToCloud({ + title: formData.title, + subTitle: formData.subTitle, + content: formData.content || '', + tags: formData.tags, + imageUrls: uploadedImages, + seriesId: formData.seriesId, + isPrivate: formData.isPrivate, + }); + await fetchCloudDrafts(); + toast.success('클라우드에 저장되었습니다.'); + } catch (error) { + toast.error('클라우드 저장 실패'); + } + } + + // 임시저장본 불러오기 + const handleLoadDraft = (draft: DraftListItem) => { + const data = draft.data; + + if (draft.source === 'local') { + const localData = data as LocalDraft; + setFormData({ + title: localData.title || '', + subTitle: localData.subTitle || '', + content: localData.content, + seriesId: localData.seriesId || '', + tags: localData.tags || [], + isPrivate: localData.isPrivate || false, + }); + setUploadedImages(draftImages || []); + } else { + const cloudData = data as CloudDraft; + setFormData({ + title: cloudData.title || '', + subTitle: cloudData.subTitle || '', + content: cloudData.content, + seriesId: cloudData.seriesId || '', + tags: cloudData.tags || [], + isPrivate: cloudData.isPrivate || false, + }); + setUploadedImages(cloudData.imageUrls || []); + } + + setDraftListOpen(false); + toast.success('임시저장본을 불러왔습니다.'); + }; + + // 임시저장본 삭제 + const handleDeleteDraft = async ( + draftId: string, + source: 'local' | 'cloud' + ) => { + try { + if (source === 'local') { + clearDraft(); + } else { + await deleteCloudDraft(draftId); + } + toast.success('임시저장이 삭제되었습니다.'); + } catch (error) { + toast.error('삭제 실패'); + } + }; + + // 임시저장본 불러오기 오버레이 열기 + const openLoadDraftOverlay = () => { + setDraftListMode('load'); + setDraftListOpen(true); + }; + + // 임시저장 삭제 오버레이 열기 + const openDeleteDraftOverlay = () => { + setDraftListMode('delete'); + setDraftListOpen(true); + }; + return (

@@ -82,8 +228,10 @@ const BlogForm = () => { seriesLoading={uiState.seriesLoading} series={seriesList} onClickNewSeries={() => setCreateSeriesOpen(true)} - onClickOverwrite={overwriteDraft} - clearDraft={clearDraftInStore} + onClickOverwrite={openLoadDraftOverlay} + clearDraft={openDeleteDraftOverlay} + autoSyncEnabled={autoSyncEnabled} + onToggleAutoSync={toggleAutoSync} /> { /> + {/* Draft List Overlay */} + + + + { submitHandler={submitHandler} submitLoading={uiState.submitLoading} saveToDraft={saveToDraft} + saveToCloud={handleSaveToCloud} /> {isEditMode && uiState.seriesLoading && ( diff --git a/app/entities/post/write/DraftListOverlay.tsx b/app/entities/post/write/DraftListOverlay.tsx new file mode 100644 index 0000000..0fb01e1 --- /dev/null +++ b/app/entities/post/write/DraftListOverlay.tsx @@ -0,0 +1,104 @@ +import { DraftListItem } from '@/app/types/Draft'; + +interface DraftListOverlayProps { + drafts: DraftListItem[]; + onLoadDraft: (draft: DraftListItem) => void; + onDeleteDraft?: (draftId: string, source: 'local' | 'cloud') => void; + mode: 'load' | 'delete'; +} + +const DraftListOverlay = ({ + drafts, + onLoadDraft, + onDeleteDraft, + mode, +}: DraftListOverlayProps) => { + const formatDate = (date: Date) => { + return new Date(date).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const handleItemClick = (draft: DraftListItem) => { + if (mode === 'load') { + onLoadDraft(draft); + } + }; + + const handleDelete = (draft: DraftListItem, e: React.MouseEvent) => { + e.stopPropagation(); + if (onDeleteDraft) { + if (confirm(`"${draft.title || '제목 없음'}" 임시저장을 삭제하시겠습니까?`)) { + onDeleteDraft(draft.id, draft.source); + } + } + }; + + return ( +
+

+ {mode === 'load' ? '임시저장본 불러오기' : '임시저장 삭제'} +

+ + {drafts.length === 0 ? ( +

+ 저장된 임시글이 없습니다. +

+ ) : ( +
+ {drafts.map((draft) => ( +
handleItemClick(draft)} + className={` + border rounded-lg p-4 transition-all + ${mode === 'load' ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700' : ''} + ${draft.source === 'local' ? 'border-blue-300' : 'border-green-300'} + `} + > +
+
+

+ {draft.title || '제목 없음'} +

+
+ + {formatDate(draft.date)} + + + {draft.source === 'local' ? '로컬' : '클라우드'} + +
+
+ + {mode === 'delete' && onDeleteDraft && ( + + )} +
+
+ ))} +
+ )} +
+ ); +}; + +export default DraftListOverlay; diff --git a/app/entities/post/write/PostMetadataForm.tsx b/app/entities/post/write/PostMetadataForm.tsx index bfcb8e0..5e6480a 100644 --- a/app/entities/post/write/PostMetadataForm.tsx +++ b/app/entities/post/write/PostMetadataForm.tsx @@ -4,6 +4,7 @@ import { FaTrash } from 'react-icons/fa'; import { FaPlus } from 'react-icons/fa6'; import Select from '@/app/entities/common/Select'; import { Series } from '@/app/types/Series'; +import AutoSyncToggle from './AutoSyncToggle'; interface PostMetadataFormProps { onFieldChange: (field: string, value: string | boolean | string[]) => void; @@ -12,6 +13,8 @@ interface PostMetadataFormProps { onClickNewSeries: () => void; onClickOverwrite: () => void; clearDraft: () => void; + autoSyncEnabled: boolean; + onToggleAutoSync: (enabled: boolean) => void; formData: { title: string; subTitle: string; @@ -28,6 +31,8 @@ const PostMetadataForm = ({ onClickNewSeries, onClickOverwrite, clearDraft, + autoSyncEnabled, + onToggleAutoSync, formData, }: PostMetadataFormProps) => { const [tagInput, setTagInput] = useState(''); @@ -180,6 +185,10 @@ const PostMetadataForm = ({

+ +
+ +
); }; diff --git a/app/entities/post/write/PostWriteButtons.tsx b/app/entities/post/write/PostWriteButtons.tsx index bc81e9d..8a3cc5a 100644 --- a/app/entities/post/write/PostWriteButtons.tsx +++ b/app/entities/post/write/PostWriteButtons.tsx @@ -8,6 +8,7 @@ interface PostWriteButtonsProps { submitLoading: boolean; submitHandler: (postBody: PostBody) => void; saveToDraft: () => void; + saveToCloud: () => void; } const PostWriteButtons = ({ slug, @@ -15,6 +16,7 @@ const PostWriteButtons = ({ submitHandler, postBody, saveToDraft, + saveToCloud, }: PostWriteButtonsProps) => { const buttonStyle = `font-bold py-2 px-4 rounded mr-2 disabled:bg-opacity-75 `; return ( @@ -31,6 +33,12 @@ const PostWriteButtons = ({ > 임시 저장 + +
+ +
From 2c9c399fb436f70002cca660cf94ab1752641c27 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Sat, 6 Dec 2025 16:03:15 +0900 Subject: [PATCH 11/12] =?UTF-8?q?style:=20=ED=98=84=EC=9E=AC=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EC=A4=91=EC=9D=B8=20=EC=9E=84=EC=8B=9C=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B3=B8=EC=9D=BC=20=EB=95=8C=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=EC=B2=98=EB=A6=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/entities/post/write/BlogForm.tsx | 2 + app/entities/post/write/DraftListOverlay.tsx | 103 +++++++++++-------- 2 files changed, 63 insertions(+), 42 deletions(-) diff --git a/app/entities/post/write/BlogForm.tsx b/app/entities/post/write/BlogForm.tsx index b8d0931..a094832 100644 --- a/app/entities/post/write/BlogForm.tsx +++ b/app/entities/post/write/BlogForm.tsx @@ -67,6 +67,7 @@ const BlogForm = () => { saveToCloud, deleteCloudDraft, toggleAutoSync, + getCurrentDraftId, } = useCloudDraft(); const [createSeriesOpen, setCreateSeriesOpen] = useState(false); @@ -251,6 +252,7 @@ const BlogForm = () => { draftListMode === 'delete' ? handleDeleteDraft : undefined } mode={draftListMode} + currentDraftId={getCurrentDraftId()} /> diff --git a/app/entities/post/write/DraftListOverlay.tsx b/app/entities/post/write/DraftListOverlay.tsx index 0fb01e1..9326d07 100644 --- a/app/entities/post/write/DraftListOverlay.tsx +++ b/app/entities/post/write/DraftListOverlay.tsx @@ -5,6 +5,7 @@ interface DraftListOverlayProps { onLoadDraft: (draft: DraftListItem) => void; onDeleteDraft?: (draftId: string, source: 'local' | 'cloud') => void; mode: 'load' | 'delete'; + currentDraftId?: string | null; } const DraftListOverlay = ({ @@ -12,6 +13,7 @@ const DraftListOverlay = ({ onLoadDraft, onDeleteDraft, mode, + currentDraftId, }: DraftListOverlayProps) => { const formatDate = (date: Date) => { return new Date(date).toLocaleDateString('ko-KR', { @@ -50,51 +52,68 @@ const DraftListOverlay = ({

) : (
- {drafts.map((draft) => ( -
handleItemClick(draft)} - className={` - border rounded-lg p-4 transition-all - ${mode === 'load' ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700' : ''} - ${draft.source === 'local' ? 'border-blue-300' : 'border-green-300'} - `} - > -
-
-

- {draft.title || '제목 없음'} -

-
- - {formatDate(draft.date)} - - - {draft.source === 'local' ? '로컬' : '클라우드'} - + {drafts.map((draft) => { + const isCurrentDraft = + draft.source === 'cloud' && draft.id === currentDraftId; + + return ( +
!isCurrentDraft && handleItemClick(draft)} + className={` + border rounded-lg p-4 transition-all + ${draft.source === 'local' ? 'border-blue-300' : 'border-green-300'} + ${isCurrentDraft + ? 'opacity-50 cursor-not-allowed' + : mode === 'load' + ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700' + : '' + } + `} + > +
+
+
+

+ {draft.title || '제목 없음'} +

+ {isCurrentDraft && ( + + 작성 중 + + )} +
+
+ + {formatDate(draft.date)} + + + {draft.source === 'local' ? '로컬' : '클라우드'} + +
-
- {mode === 'delete' && onDeleteDraft && ( - - )} + {mode === 'delete' && onDeleteDraft && ( + + )} +
-
- ))} + ); + })}
)}
From 150e3302b1f6af092d1fd847f8737a01cd453533 Mon Sep 17 00:00:00 2001 From: sjw4371 Date: Sat, 6 Dec 2025 16:06:30 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20git=20action=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=98=EA=B3=A0=20lint,=20type=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=A7=8C=20=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-test.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index f5542ce..b4d924a 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -36,12 +36,6 @@ jobs: - name: Lint check run: pnpm lint - # 빌드 테스트 - - name: Build test - run: pnpm build - env: - DB_URI: ${{ secrets.DB_URI }} - # cd: # name: Continuous Deployment # needs: ci