diff --git a/frontend/src/app/(post)/_components/editor/RecordEditor.tsx b/frontend/src/app/(post)/_components/editor/RecordEditor.tsx index 96a3d3571..c7302b817 100644 --- a/frontend/src/app/(post)/_components/editor/RecordEditor.tsx +++ b/frontend/src/app/(post)/_components/editor/RecordEditor.tsx @@ -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'; @@ -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'; @@ -85,6 +86,7 @@ interface PostEditorProps { const BlockItem = memo(function BlockItem({ block, isDraggingId, + isDeleting, locks, mySessionId, members, @@ -99,6 +101,7 @@ const BlockItem = memo(function BlockItem({ }: { block: RecordBlock; isDraggingId: string | null; + isDeleting: boolean; locks: Record; mySessionId: string | null; members: PresenceMember[]; @@ -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 && ( +
+ +
+ )}
{ + setIsSaving(false); + if (isPersonalNew) clearDraftAndPhotos(); + }, }); // 네비게이션이 실패하거나 느린 경우 로딩 화면이 무한히 뜨는 것을 방지하는 안전망 @@ -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]); @@ -316,6 +324,7 @@ export default function PostEditor({ handleDone, addOrShowBlock, removeBlock, + deletingBlockIds, //handleApplyTemplate, } = usePostEditorBlocks({ blocks, @@ -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}"` : '제목 없음', @@ -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} diff --git a/frontend/src/app/(post)/_hooks/usePostEditorBlocks.ts b/frontend/src/app/(post)/_hooks/usePostEditorBlocks.ts index e9fd99fc5..cae0ce3e2 100644 --- a/frontend/src/app/(post)/_hooks/usePostEditorBlocks.ts +++ b/frontend/src/app/(post)/_hooks/usePostEditorBlocks.ts @@ -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'; @@ -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[]; @@ -64,6 +69,66 @@ export function usePostEditorBlocks({ const fileInputRef = useRef(null); + const { socket } = useSocketStore(); + + // 삭제 요청을 보냈지만 백엔드 커밋 확인을 기다리는 중인 블록 id 집합. + // (비관적 삭제: 커밋 확인 전까지 블록을 화면에 "삭제 중" 상태로 남겨둔다) + const [deletingBlockIds, setDeletingBlockIds] = useState>( + new Set(), + ); + const deleteTimeoutsRef = useRef>(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[]; @@ -79,9 +144,6 @@ export function usePostEditorBlocks({ }; } | null>(null); - // TODO: 추후 고려해볼 사항 - // 타입이 필드면 락 걸고 삭제하고 락 풀고 - // 타입이 드로어라면, 바로 삭제만하고(삭제전에 락 검증해야할까?) const removeBlock = useCallback( (id: string) => { // 블록 타입 확인하여 메타데이터 적용 상태 업데이트 @@ -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, @@ -152,6 +243,7 @@ export function usePostEditorBlocks({ setBlocks, locks, mySessionId, + deletingBlockIds, ], ); @@ -298,7 +390,9 @@ export function usePostEditorBlocks({ // 타인이 락을 쥐고 있다면 동작 차단 if (isLockedByOther) { - toast.error('현재 다른 사용자가 편집 중입니다.', { id: `locked-${lockKey}` }); + toast.error('현재 다른 사용자가 편집 중입니다.', { + id: `locked-${lockKey}`, + }); return; } } @@ -509,6 +603,7 @@ export function usePostEditorBlocks({ handleDone, addOrShowBlock, removeBlock, + deletingBlockIds, handleApplyTemplate, applyPendingMetadata, handleEditMetadata, diff --git a/frontend/src/app/(post)/_hooks/useRecordEditorDnD.ts b/frontend/src/app/(post)/_hooks/useRecordEditorDnD.ts index a6530ccde..093408771 100644 --- a/frontend/src/app/(post)/_hooks/useRecordEditorDnD.ts +++ b/frontend/src/app/(post)/_hooks/useRecordEditorDnD.ts @@ -7,6 +7,7 @@ import { useHaptic } from '@/hooks/useHaptic'; const LONG_PRESS_DURATION = 300; // ms — 이 시간 이상 누르고 있어야 드래그 시작 const SCROLL_CANCEL_THRESHOLD = 6; // px — 롱프레스 대기 중 이 이상 움직이면 스크롤로 판단해 취소 +const MOUSE_DRAG_THRESHOLD = 6; // px — 마우스(데스크탑) 드래그 시작 이동 거리 임계값 export const useRecordEditorDnD = ( blocks: RecordBlock[], @@ -30,7 +31,7 @@ export const useRecordEditorDnD = ( blockId: string; startX: number; startY: number; - timer: ReturnType; + timer: ReturnType | null; } | null>(null); // touch 이벤트 기반 롱프레스 대기 (textarea/input — iOS pointercancel 우회) @@ -62,6 +63,19 @@ export const useRecordEditorDnD = ( const blockId = blockEl.getAttribute('data-block-id'); if (!blockId) return; + // 마우스(데스크탑): 타이머 없이 펜딩만 설정 — pointermove에서 움직임 기준으로 드래그 시작 + if (e.pointerType === 'mouse') { + pendingDragRef.current = { + pointerId: e.pointerId, + blockId, + startX: e.clientX, + startY: e.clientY, + timer: null, + }; + return; + } + + // 터치/펜: 기존 롱프레스 방식 const timer = setTimeout(() => { if (pendingDragRef.current?.blockId === blockId) { // 롱프레스 완료 → 드래그 활성화 @@ -264,12 +278,34 @@ export const useRecordEditorDnD = ( ) { const dx = e.clientX - pendingDragRef.current.startX; const dy = e.clientY - pendingDragRef.current.startY; - // 롱프레스 대기 중 스크롤 의도 감지 → 타이머 취소 - if ( - dx * dx + dy * dy > - SCROLL_CANCEL_THRESHOLD * SCROLL_CANCEL_THRESHOLD - ) { - clearTimeout(pendingDragRef.current.timer); + const distSq = dx * dx + dy * dy; + + if (e.pointerType === 'mouse') { + // 마우스(데스크탑): 움직임 임계값 초과 시 즉시 드래그 활성화 + if (distSq > MOUSE_DRAG_THRESHOLD * MOUSE_DRAG_THRESHOLD) { + const { blockId } = pendingDragRef.current; + const el = document.querySelector( + `[data-block-id="${blockId}"]`, + ) as HTMLElement | null; + if (el) { + try { + el.setPointerCapture(e.pointerId); + } catch {} + pointerIdRef.current = e.pointerId; + capturedElementRef.current = el; + } + isPointerDraggingRef.current = true; + isDraggingIdRef.current = blockId; + setIsDraggingId(blockId); + pendingDragRef.current = null; + } + return; + } + + // 터치/펜: 롱프레스 대기 중 스크롤 의도 감지 → 타이머 취소 + if (distSq > SCROLL_CANCEL_THRESHOLD * SCROLL_CANCEL_THRESHOLD) { + if (pendingDragRef.current.timer) + clearTimeout(pendingDragRef.current.timer); pendingDragRef.current = null; } return; @@ -317,7 +353,7 @@ export const useRecordEditorDnD = ( pendingDragRef.current && e.pointerId === pendingDragRef.current.pointerId ) { - clearTimeout(pendingDragRef.current.timer); + clearTimeout(pendingDragRef.current.timer ?? undefined); pendingDragRef.current = null; } }; @@ -357,7 +393,7 @@ export const useRecordEditorDnD = ( const handleDragEnd = useCallback(() => { if (pendingDragRef.current) { - clearTimeout(pendingDragRef.current.timer); + clearTimeout(pendingDragRef.current.timer ?? undefined); pendingDragRef.current = null; } if (pendingTouchDragRef.current) { diff --git a/frontend/src/hooks/useHaptic.ts b/frontend/src/hooks/useHaptic.ts index 17e26feca..594c83cd2 100644 --- a/frontend/src/hooks/useHaptic.ts +++ b/frontend/src/hooks/useHaptic.ts @@ -1,8 +1,13 @@ import { useCallback, useRef } from 'react'; -const isIOS = () => - typeof navigator !== 'undefined' && - /iPad|iPhone|iPod/.test(navigator.userAgent); +const isIOS = () => { + if (typeof navigator === 'undefined') return false; + const iOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent); + // iPadOS 13+는 userAgent에 "iPad"가 없고 MacIntel로 표시됨 + const iPadOS = + navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; + return iOSDevice || iPadOS; +}; export function useHaptic() { const labelElRef = useRef(null); @@ -14,6 +19,8 @@ export function useHaptic() { const input = document.createElement('input'); input.type = 'checkbox'; input.id = '__haptic_trigger'; + // iOS 18+ WebKit이 switch 타입 체크박스를 토글할 때만 햅틱을 발생시킴 + input.setAttribute('switch', ''); input.style.cssText = 'position:fixed;opacity:0;pointer-events:none;width:0;height:0'; @@ -32,7 +39,7 @@ export function useHaptic() { (duration = 30) => { try { if (isIOS()) { - // iOS: hidden checkbox toggle → Taptic Engine 발동 + // iOS: hidden switch checkbox toggle → Taptic Engine 발동 getLabelEl()?.click(); } else if (navigator.vibrate) { navigator.vibrate(duration); diff --git a/frontend/src/lib/date.ts b/frontend/src/lib/date.ts index dc8c1897b..a96ee45ea 100644 --- a/frontend/src/lib/date.ts +++ b/frontend/src/lib/date.ts @@ -63,7 +63,7 @@ export function formatTimeStr(timeStr: string): string { /** * 날짜를 상대적인 시간으로 표시 * @param date - 비교할 날짜 - * @returns "방금 전", "5분 전", "어제", "3일 전" 등의 문자열 + * @returns "방금 전", "5분 전", "하루 전", "3일 전" 등의 문자열 * @example * formatRelativeTime(new Date()) // "방금 전" * formatRelativeTime(new Date(Date.now() - 1000 * 60 * 5)) // "5분 전" @@ -82,9 +82,11 @@ export function formatRelativeTime(date: Date): string { if (seconds < 60) return '방금 전'; if (minutes < 60) return `${minutes}분 전`; if (hours < 24) return `${hours}시간 전`; - if (days === 1) return '어제'; + if (days === 1) return '하루 전'; if (days < 30) return `${days}일 전`; - if (months < 12) return `${months}개월 전`; + if (months === 1) return '한 달 전'; + if (months === 2) return '두 달 전'; + if (months < 12) return `${months}달 전`; return `${years}년 전`; } diff --git a/frontend/src/lib/utils/error/publishHandler.ts b/frontend/src/lib/utils/error/publishHandler.ts index dbcd078ad..b7f9a734d 100644 --- a/frontend/src/lib/utils/error/publishHandler.ts +++ b/frontend/src/lib/utils/error/publishHandler.ts @@ -36,5 +36,9 @@ export const handlePublishError = ( if (handler) { handler(); + } else { + // 알려지지 않은 에러 코드(네트워크 오류, 예기치 못한 서버 오류 등)도 + // 사용자에게 실패를 알려야 로딩 화면만 조용히 사라지는 상황을 막을 수 있다. + toast.error('기록 저장에 실패했습니다. 다시 시도해 주세요.'); } }; diff --git a/frontend/src/lib/utils/errorHandler.ts b/frontend/src/lib/utils/errorHandler.ts index 30389789f..68d6ae814 100644 --- a/frontend/src/lib/utils/errorHandler.ts +++ b/frontend/src/lib/utils/errorHandler.ts @@ -19,6 +19,7 @@ export const ERROR_CODES = { SESSION_INVALID: 'SESSION_INVALID', UNAUTHORIZED: 'UNAUTHORIZED', NETWORK_ERROR: 'NETWORK_ERROR', + INVALID_AUTH_CODE: 'INVALID_AUTH_CODE', } as const; const REFRESHABLE_AUTH_ERRORS: string[] = [ @@ -34,6 +35,7 @@ const TERMINAL_AUTH_ERRORS: string[] = [ ERROR_CODES.REFRESH_TOKEN_REUSE_DETECTED, ERROR_CODES.REFRESH_TOKEN_EXPIRED, ERROR_CODES.SESSION_INVALID, + ERROR_CODES.INVALID_AUTH_CODE, ]; /**