Skip to content
Open
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
29 changes: 19 additions & 10 deletions frontend/src/app/(post)/_components/editor/RecordEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useEffect, useState, useRef, useCallback, memo, useMemo } from 'react';
import { GripVertical, User } from 'lucide-react';
import { GripVertical, Loader2, User } from 'lucide-react';

// 컴포넌트 및 필드 임포트
import RecordEditorHeader from './RecordEditorHeader';
Expand Down Expand Up @@ -71,6 +71,7 @@ import LocationDrawer from '@/components/map/LocationDrawer';
import { toast } from 'sonner';
import { groupDetailOptions } from '@/lib/api/group';
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@/lib/date';

interface PostEditorProps {
mode: 'add' | 'edit';
Expand All @@ -85,6 +86,7 @@ interface PostEditorProps {
const BlockItem = memo(function BlockItem({
block,
isDraggingId,
isDeleting,
locks,
mySessionId,
members,
Expand All @@ -99,6 +101,7 @@ const BlockItem = memo(function BlockItem({
}: {
block: RecordBlock;
isDraggingId: string | null;
isDeleting: boolean;
locks: Record<string, string>;
mySessionId: string | null;
members: PresenceMember[];
Expand Down Expand Up @@ -158,8 +161,13 @@ const BlockItem = memo(function BlockItem({
data-block-id={block.id}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
className={`cursor-grab relative group/field select-none ${isDraggingId ? 'touch-none' : 'touch-auto'} ${block.layout.span === 1 ? 'col-span-1' : 'col-span-2'} ${isDraggingId === block.id ? 'opacity-20 scale-95' : 'opacity-100'} ${!isDraggingId ? 'transition-all duration-300' : ''}`}
className={`cursor-grab relative group/field select-none ${isDraggingId ? 'touch-none' : 'touch-auto'} ${block.layout.span === 1 ? 'col-span-1' : 'col-span-2'} ${isDraggingId === block.id ? 'opacity-20 scale-95' : isDeleting ? 'opacity-40 pointer-events-none' : 'opacity-100'} ${!isDraggingId ? 'transition-all duration-300' : ''}`}
>
{isDeleting && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/40 dark:bg-black/40">
<Loader2 className="w-5 h-5 animate-spin text-itta-point" />
</div>
)}
<div
className={`relative w-full flex flex-row gap-2 items-center ${isDraggingId ? 'touch-none' : 'touch-auto'} ${
block.layout.col === 1 ? 'justify-start' : 'justify-end'
Expand Down Expand Up @@ -286,7 +294,10 @@ export default function PostEditor({
setIsSaving(false);
resetNavigatingToRecord();
},
onSuccess: isPersonalNew ? clearDraftAndPhotos : undefined,
onSuccess: () => {
setIsSaving(false);
if (isPersonalNew) clearDraftAndPhotos();
},
});

// 네비게이션이 실패하거나 느린 경우 로딩 화면이 무한히 뜨는 것을 방지하는 안전망
Expand All @@ -296,14 +307,11 @@ export default function PostEditor({
return () => clearTimeout(timer);
}, [isPublishing, setIsPublishing]);

// 개인 기록 저장 응답이 너무 오래 걸릴 경우 로딩 화면 해제 및 알림
// 저장 응답이 오래 걸릴 경우 지연 안내 (로딩 화면은 유지)
useEffect(() => {
if (!isSaving) return;
const timer = setTimeout(() => {
setIsSaving(false);
toast.error(
'네트워크 지연 또는 기록 저장에 실패했습니다. 다시 시도해 주세요.',
);
toast.info('저장이 지연되고 있습니다. 잠시 후 자동으로 완료됩니다.');
}, 10_000);
return () => clearTimeout(timer);
}, [isSaving]);
Comment thread
H-sooyeon marked this conversation as resolved.
Expand All @@ -316,6 +324,7 @@ export default function PostEditor({
handleDone,
addOrShowBlock,
removeBlock,
deletingBlockIds,
//handleApplyTemplate,
} = usePostEditorBlocks({
blocks,
Expand Down Expand Up @@ -439,8 +448,7 @@ export default function PostEditor({
if (!draft) return;

const savedDate = new Date(draft.savedAt);
const minutes = Math.floor((Date.now() - savedDate.getTime()) / 60_000);
const timeLabel = minutes < 1 ? '방금' : `${minutes}분 전`;
const timeLabel = formatRelativeTime(savedDate);

toast(`${timeLabel} 작성하던 기록이 있어요`, {
description: draft.title ? `"${draft.title}"` : '제목 없음',
Expand Down Expand Up @@ -1135,6 +1143,7 @@ export default function PostEditor({
key={block.id}
block={block}
isDraggingId={isDraggingId}
isDeleting={deletingBlockIds.has(block.id)}
locks={locks}
mySessionId={mySessionId}
members={members}
Expand Down
145 changes: 120 additions & 25 deletions frontend/src/app/(post)/_hooks/usePostEditorBlocks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { v4 as uuidv4 } from 'uuid';

import { FieldType, LocationValue } from '@/lib/types/record';
Expand All @@ -20,6 +20,11 @@ import {
} from '@/lib/utils/exifExtractor';
import * as Sentry from '@sentry/nextjs';
import { logger } from '@/lib/utils/logger';
import { useSocketStore } from '@/store/useSocketStore';

// 블록 삭제 응답을 기다리는 최대 시간. 이 시간 내에 PATCH_COMMITTED로
// 삭제가 확정되지 않으면(락 미보유로 백엔드가 거부한 경우 등) 원상복구한다.
const DELETE_CONFIRM_TIMEOUT_MS = 8_000;

interface UsePostEditorBlocksProps {
blocks: RecordBlock[];
Expand Down Expand Up @@ -64,6 +69,66 @@ export function usePostEditorBlocks({

const fileInputRef = useRef<HTMLInputElement>(null);

const { socket } = useSocketStore();

// 삭제 요청을 보냈지만 백엔드 커밋 확인을 기다리는 중인 블록 id 집합.
// (비관적 삭제: 커밋 확인 전까지 블록을 화면에 "삭제 중" 상태로 남겨둔다)
const [deletingBlockIds, setDeletingBlockIds] = useState<Set<string>>(
new Set(),
);
const deleteTimeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());

const finalizeBlockDeletion = useCallback((id: string) => {
const timeout = deleteTimeoutsRef.current.get(id);
if (timeout) {
clearTimeout(timeout);
deleteTimeoutsRef.current.delete(id);
}
setDeletingBlockIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);

// 내가 보낸 BLOCK_DELETE가 실제로 커밋됐는지 PATCH_COMMITTED로 확인한다.
// (락을 보유하지 않은 채 삭제를 시도하면 백엔드가 거부할 수 있는데,
// 이 경우 PATCH_COMMITTED가 오지 않으므로 아래 타임아웃에서 원상복구된다)
useEffect(() => {
if (!socket || !draftId) return;

const handlePatchCommitted = ({
patch,
}: {
patch: PatchApplyPayload | PatchApplyPayload[];
}) => {
const commands = Array.isArray(patch) ? patch : [patch];
commands.forEach((cmd) => {
if (cmd.type !== 'BLOCK_DELETE') return;
if (!deleteTimeoutsRef.current.has(cmd.blockId)) return;

finalizeBlockDeletion(cmd.blockId);
setBlocks((prev) =>
normalizeLayout(prev.filter((b) => b.id !== cmd.blockId)),
);
});
};

socket.on('PATCH_COMMITTED', handlePatchCommitted);
return () => {
socket.off('PATCH_COMMITTED', handlePatchCommitted);
};
}, [socket, draftId, finalizeBlockDeletion, setBlocks]);

// 언마운트 시 남아있는 삭제 대기 타임아웃 정리
useEffect(() => {
return () => {
deleteTimeoutsRef.current.forEach((timeout) => clearTimeout(timeout));
deleteTimeoutsRef.current.clear();
};
}, []);

// EXIF 메타데이터 상태
const [pendingMetadata, setPendingMetadata] = useState<{
images: ImageWithMetadata[];
Expand All @@ -79,9 +144,6 @@ export function usePostEditorBlocks({
};
} | null>(null);

// TODO: 추후 고려해볼 사항
// 타입이 필드면 락 걸고 삭제하고 락 풀고
// 타입이 드로어라면, 바로 삭제만하고(삭제전에 락 검증해야할까?)
const removeBlock = useCallback(
(id: string) => {
// 블록 타입 확인하여 메타데이터 적용 상태 업데이트
Expand Down Expand Up @@ -117,31 +179,60 @@ export function usePostEditorBlocks({
}
}

if (draftId) {
const lockKey = `block:${id}`;
const ownerSessionId = locks?.[lockKey];
const isLockedByOther = !!ownerSessionId && ownerSessionId !== mySessionId;
// 공동작성 중이 아니면(draftId 없음) 락 개념이 없으므로 즉시 로컬 삭제
if (!draftId) {
setBlocks((prev) => normalizeLayout(prev.filter((b) => b.id !== id)));
return;
}

// 다른 사용자가 편집 중인 블록은 삭제 차단
if (isLockedByOther) {
toast.error('다른 사용자가 편집 중인 필드는 삭제할 수 없습니다.', {
id: `delete-locked-${lockKey}`,
});
return;
}
if (deletingBlockIds.has(id)) return; // 이미 삭제 요청 중

const lockKey = `block:${id}`;
const ownerSessionId = locks?.[lockKey];
const isLockedByOther =
!!ownerSessionId && ownerSessionId !== mySessionId;

applyPatch({
type: 'BLOCK_DELETE',
blockId: id,
// 다른 사용자가 편집 중인 블록은 삭제 차단
if (isLockedByOther) {
toast.error('다른 사용자가 편집 중인 필드는 삭제할 수 없습니다.', {
id: `delete-locked-${lockKey}`,
});
// 내가 락을 쥐고 있었다면 해제
if (ownerSessionId === mySessionId) {
releaseLock(lockKey);
}
return;
}

// 로컬 상태 반영 및 레이아웃 정규화
setBlocks((prev) => normalizeLayout(prev.filter((b) => b.id !== id)));
// 비관적 삭제: 락을 보유하지 않은 채로 곧바로 로컬에서 지워버리면,
// 백엔드가 락 없음을 이유로 삭제를 거부했을 때 프론트와 백엔드의 블록 목록이
// 어긋나 저장 시 같은 위치에 두 블록이 겹치는 문제가 있었음.
// PATCH_COMMITTED로 삭제가 커밋된 걸 확인하기 전까지는 "삭제 중" 상태로만 표시한다.
setDeletingBlockIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});

applyPatch({
type: 'BLOCK_DELETE',
blockId: id,
});
// 내가 락을 쥐고 있었다면 해제
if (ownerSessionId === mySessionId) {
releaseLock(lockKey);
}

// 타임아웃 내에 PATCH_COMMITTED가 오지 않으면(백엔드 거부, 응답 지연 등) 원상복구
const timeout = setTimeout(() => {
deleteTimeoutsRef.current.delete(id);
setDeletingBlockIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
toast.error('삭제에 실패했습니다. 다시 시도해 주세요.', {
id: `delete-failed-${id}`,
});
}, DELETE_CONFIRM_TIMEOUT_MS);
deleteTimeoutsRef.current.set(id, timeout);
},
[
blocks,
Expand All @@ -152,6 +243,7 @@ export function usePostEditorBlocks({
setBlocks,
locks,
mySessionId,
deletingBlockIds,
],
);

Expand Down Expand Up @@ -298,7 +390,9 @@ export function usePostEditorBlocks({

// 타인이 락을 쥐고 있다면 동작 차단
if (isLockedByOther) {
toast.error('현재 다른 사용자가 편집 중입니다.', { id: `locked-${lockKey}` });
toast.error('현재 다른 사용자가 편집 중입니다.', {
id: `locked-${lockKey}`,
});
return;
}
}
Expand Down Expand Up @@ -509,6 +603,7 @@ export function usePostEditorBlocks({
handleDone,
addOrShowBlock,
removeBlock,
deletingBlockIds,
handleApplyTemplate,
applyPendingMetadata,
handleEditMetadata,
Expand Down
Loading