From c7d895e70e78a304cd34ca296c2a37cd53ca3f01 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 20:19:46 +0900 Subject: [PATCH 01/12] =?UTF-8?q?#66=20[UI]=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DetailView/Toast.tsx | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/components/DetailView/Toast.tsx diff --git a/src/components/DetailView/Toast.tsx b/src/components/DetailView/Toast.tsx new file mode 100644 index 00000000..d0677661 --- /dev/null +++ b/src/components/DetailView/Toast.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +interface ToastProps { + title: string; + contents?: ReactNode; +} + +const Toast = ({ + title = '알림', + contents = <>최대 20자까지 작성할 수 있습니다., +}: ToastProps) => { + return createPortal( +
+
{ + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > +

{title}

+

{contents}

+
+
, + document.body + ); +}; + +export default Toast; From f9fd45fe0a42bccfeaaaff4511a163836d91b8bb Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 21:03:27 +0900 Subject: [PATCH 02/12] =?UTF-8?q?#66=20[FEAT]=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=81=ED=83=9C,=20=EC=8B=9C=EA=B0=84,=20?= =?UTF-8?q?=EC=8A=A4=ED=83=9D=EC=8C=93=EA=B8=B0=20=EB=93=B1=EC=9D=84=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=ED=95=98=EB=8A=94=20ToastProvider=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Toast/ToastProvider.tsx | 184 +++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/components/Toast/ToastProvider.tsx diff --git a/src/components/Toast/ToastProvider.tsx b/src/components/Toast/ToastProvider.tsx new file mode 100644 index 00000000..92a962c8 --- /dev/null +++ b/src/components/Toast/ToastProvider.tsx @@ -0,0 +1,184 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; + +export const MAX_VISIBLE = 3; // 한번에 쌓아서 보여줄 수 있는 최대 토스트 개수 +export const DEFAULT_DURATION = 2000; // 자동 닫힘까지 걸리는 시간(ms) + +export type ShowToastArguments = { + title?: string; + contents: ReactNode; + key?: string; // 같은 key면 기존 토스트 갱신 + 타이머 리셋 + duration?: number; +}; + +export type ToastItem = Required> & { + id: number; + key?: string; + duration: number; +}; + +type ToastCtx = { + visible: ToastItem[]; // 현재 화면에 보이는 토스트들(스택) + showToast: (args: ShowToastArguments) => void; // 토스트 띄우기 + dismissToast: (id: number) => void; // 토스트 수동 닫기 +}; + +const ToastContext = createContext(null); + +export const useToast = () => { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast는 내에서 사용되어야 합니다.'); + return ctx; +}; + +const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [visible, setVisible] = useState([]); + const [, setQueue] = useState([]); + const idRef = useRef(0); + const timersRef = useRef>(new Map()); + + const startTimer = useCallback((toast: ToastItem) => { + const timer = window.setTimeout(() => { + // 자동 닫힘 + setVisible((prev) => prev.filter((t) => t.id !== toast.id)); + timersRef.current.delete(toast.id); + + // 빈 칸 생기면 큐에서 하나 꺼내기 + setQueue((q) => { + if (q.length === 0) return q; + const [next, ...rest] = q; + setVisible((prev) => { + const updated = [...prev, next]; + // 다음 토스트 타이머 시작 + startTimer(next); + return updated; + }); + return rest; + }); + }, toast.duration); + timersRef.current.set(toast.id, timer); + }, []); + + const dismissToast = useCallback( + (id: number) => { + const timer = timersRef.current.get(id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(id); + } + setVisible((prev) => prev.filter((t) => t.id !== id)); + + // 닫히면 빈 칸 채우기 + setQueue((q) => { + if (q.length === 0) return q; + const [next, ...rest] = q; + setVisible((prev) => { + if (prev.length >= MAX_VISIBLE) return prev; + startTimer(next); + return [...prev, next]; + }); + return rest; + }); + }, + [startTimer] + ); + + const showToast = useCallback( + (args: ShowToastArguments) => { + const item: ToastItem = { + id: ++idRef.current, + title: args.title ?? '알림', + contents: args.contents, + key: args.key, + duration: args.duration ?? DEFAULT_DURATION, + }; + + // 1) key 중복합치기: visible/queue에서 같은 key를 찾아 갱신 + 타이머 리셋 + if (item.key) { + let updated = false; + + setVisible((prev) => { + const index = prev.findIndex((t) => t.key === item.key); + if (index >= 0) { + const old = prev[index]; + // 타이머 리셋 + const h = timersRef.current.get(old.id); + if (h) { + clearTimeout(h); + timersRef.current.delete(old.id); + } + const merged: ToastItem = { + ...old, + title: item.title, + contents: item.contents, + duration: item.duration, + }; + startTimer(merged); + const clone = prev.slice(); + clone[index] = merged; + updated = true; + return clone; + } + return prev; + }); + + if (!updated) { + setQueue((prev) => { + const index = prev.findIndex((t) => t.key === item.key); + if (index >= 0) { + const clone = prev.slice(); + clone[index] = { + ...clone[index], + title: item.title, + contents: item.contents, + duration: item.duration, + }; + updated = true; + return clone; + } + return prev; + }); + } + + if (updated) return; // 이미 갱신됐으면 여기서 끝 + } + + // 2) 자리가 있으면 visible에 올리고 타이머 시작 + setVisible((prev) => { + if (prev.length < MAX_VISIBLE) { + startTimer(item); + return [...prev, item]; + } + // 3) 아니면 큐에 적재 + setQueue((q) => [...q, item]); + return prev; + }); + }, + [startTimer] + ); + + // 언마운트 시 모든 타이머 정리 + useEffect(() => { + return () => { + timersRef.current.forEach((h) => clearTimeout(h)); + timersRef.current.clear(); + }; + }, []); + + const ctx = useMemo( + () => ({ visible, showToast, dismissToast }), + [visible, showToast, dismissToast] + ); + + return {children}; +}; + +export default ToastProvider; From e82b61029d37ba7147273248a0cc0742568d25a1 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 21:08:42 +0900 Subject: [PATCH 03/12] =?UTF-8?q?#66=20[MOD]=20React.FC=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20props=20=ED=83=80=EC=9E=85=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EC=84=A0=EC=96=B8=ED=95=98=EB=8A=94=20=EA=B2=83=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Toast/ToastProvider.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Toast/ToastProvider.tsx b/src/components/Toast/ToastProvider.tsx index 92a962c8..943b17bc 100644 --- a/src/components/Toast/ToastProvider.tsx +++ b/src/components/Toast/ToastProvider.tsx @@ -9,6 +9,8 @@ import { type ReactNode, } from 'react'; +type ToastProviderProps = { children: ReactNode }; + export const MAX_VISIBLE = 3; // 한번에 쌓아서 보여줄 수 있는 최대 토스트 개수 export const DEFAULT_DURATION = 2000; // 자동 닫힘까지 걸리는 시간(ms) @@ -39,7 +41,7 @@ export const useToast = () => { return ctx; }; -const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const ToastProvider = ({ children }: ToastProviderProps) => { const [visible, setVisible] = useState([]); const [, setQueue] = useState([]); const idRef = useRef(0); @@ -180,5 +182,3 @@ const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => return {children}; }; - -export default ToastProvider; From f75a5dda29f2820fc6812a581a31ebb3c36d5b83 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 21:12:57 +0900 Subject: [PATCH 04/12] =?UTF-8?q?#66=20[MOD]=20Toast=20->=20ToastViewport?= =?UTF-8?q?=EB=A1=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=ED=95=98=EA=B3=A0=20=EC=97=AD=ED=95=A0=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC(props=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80?= =?UTF-8?q?=20=EC=95=8A=EA=B3=A0=20useToast=EB=A5=BC=20=ED=86=B5=ED=95=B4?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DetailView/Toast.tsx | 37 -------------------------- src/components/Toast/ToastViewport.tsx | 34 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 37 deletions(-) delete mode 100644 src/components/DetailView/Toast.tsx create mode 100644 src/components/Toast/ToastViewport.tsx diff --git a/src/components/DetailView/Toast.tsx b/src/components/DetailView/Toast.tsx deleted file mode 100644 index d0677661..00000000 --- a/src/components/DetailView/Toast.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { ReactNode } from 'react'; -import { createPortal } from 'react-dom'; - -interface ToastProps { - title: string; - contents?: ReactNode; -} - -const Toast = ({ - title = '알림', - contents = <>최대 20자까지 작성할 수 있습니다., -}: ToastProps) => { - return createPortal( -
-
{ - e.stopPropagation(); - }} - onClick={(e) => { - e.stopPropagation(); - }} - > -

{title}

-

{contents}

-
-
, - document.body - ); -}; - -export default Toast; diff --git a/src/components/Toast/ToastViewport.tsx b/src/components/Toast/ToastViewport.tsx new file mode 100644 index 00000000..3d178038 --- /dev/null +++ b/src/components/Toast/ToastViewport.tsx @@ -0,0 +1,34 @@ +import { createPortal } from 'react-dom'; +import { useToast } from './ToastProvider'; + +const ToastViewport = () => { + const { visible } = useToast(); + + return createPortal( +
+ {visible.map((t) => ( +
{ + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + > +

{t.title}

+

{t.contents}

+
+ ))} +
, + document.body + ); +}; + +export default ToastViewport; From 2f6f98ac79222a833686ea7ad551e5e4fe8b2945 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 21:14:34 +0900 Subject: [PATCH 05/12] =?UTF-8?q?#66=20[FEAT]=20=EB=A3=A8=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20ToastProvider=EB=A1=9C=20=EC=95=B1=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EB=A5=BC=20=EA=B0=90=20=EC=8B=B8=EA=B8=B0=20+=20?= =?UTF-8?q?=ED=8F=AC=ED=84=B8=EB=A1=9C=20=EC=9A=B0=EC=B8=A1=20=ED=95=98?= =?UTF-8?q?=EB=8B=A8=EC=97=90=20ToastViewport=20=EB=A0=8C=EB=8D=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 1173608d..da6b581c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,11 +3,17 @@ import router from './routes'; import { QueryClientProvider } from '@tanstack/react-query'; import queryClient from './utils/queryClient.ts'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { ToastProvider } from './components/Toast/ToastProvider.tsx'; +import ToastViewport from './components/Toast/ToastViewport.tsx'; function App() { return ( - + + + + + {import.meta.env.DEV && } ); From 8f32ece1ffea5663d04682519bcae95f9d39fb7b Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 21:37:09 +0900 Subject: [PATCH 06/12] =?UTF-8?q?#66=20[UI]=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0,=20=EC=9C=84=EC=B9=98,=20=ED=8C=A8?= =?UTF-8?q?=EB=94=A9=EA=B0=92=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Toast/ToastViewport.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Toast/ToastViewport.tsx b/src/components/Toast/ToastViewport.tsx index 3d178038..ee66c1d8 100644 --- a/src/components/Toast/ToastViewport.tsx +++ b/src/components/Toast/ToastViewport.tsx @@ -6,7 +6,7 @@ const ToastViewport = () => { return createPortal(
{ {visible.map((t) => (
{ e.stopPropagation(); }} From 7e8b376568b2cb681dc287cd58b2d91436a44dc4 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 22:12:16 +0900 Subject: [PATCH 07/12] =?UTF-8?q?#66=20[MOD]=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EB=85=B8=EC=B6=9C=20=EB=B0=A9=EC=A7=80?= =?UTF-8?q?=20=EB=B0=8F=20=EB=82=98=ED=83=80=EB=82=98=EA=B3=A0=20=EC=82=AC?= =?UTF-8?q?=EB=9D=BC=EC=A7=80=EB=8A=94=20=EB=AA=A8=EC=85=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Toast/ToastProvider.tsx | 194 +++++++++++++++---------- src/components/Toast/ToastViewport.tsx | 68 +++++++-- 2 files changed, 172 insertions(+), 90 deletions(-) diff --git a/src/components/Toast/ToastProvider.tsx b/src/components/Toast/ToastProvider.tsx index 943b17bc..3d7069e7 100644 --- a/src/components/Toast/ToastProvider.tsx +++ b/src/components/Toast/ToastProvider.tsx @@ -9,28 +9,28 @@ import { type ReactNode, } from 'react'; -type ToastProviderProps = { children: ReactNode }; - -export const MAX_VISIBLE = 3; // 한번에 쌓아서 보여줄 수 있는 최대 토스트 개수 -export const DEFAULT_DURATION = 2000; // 자동 닫힘까지 걸리는 시간(ms) +export const MAX_VISIBLE = 3; // 한번에 보일 최대 토스트 수 +export const DEFAULT_DURATION = 2000; // 자동 닫힘 시간(ms) +export const ANIMATION_DURATION = 400; // 입/퇴장 애니메이션 시간(ms) export type ShowToastArguments = { title?: string; contents: ReactNode; key?: string; // 같은 key면 기존 토스트 갱신 + 타이머 리셋 - duration?: number; + duration?: number; // 자동 닫힘 시간 }; export type ToastItem = Required> & { - id: number; + id: string; // 전역 고유 id (randomUUID 기반) key?: string; duration: number; + closing?: boolean; // 내려가는 애니메이션 중인지 }; type ToastCtx = { - visible: ToastItem[]; // 현재 화면에 보이는 토스트들(스택) - showToast: (args: ShowToastArguments) => void; // 토스트 띄우기 - dismissToast: (id: number) => void; // 토스트 수동 닫기 + visible: ToastItem[]; // 현재 화면에 있는 토스트들 + showToast: (args: ShowToastArguments) => void; + dismissToast: (id: string) => void; }; const ToastContext = createContext(null); @@ -41,91 +41,135 @@ export const useToast = () => { return ctx; }; -export const ToastProvider = ({ children }: ToastProviderProps) => { +// 전역 고유 id 생성기 +const nextToastId = (): string => { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + return `t_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +}; + +// 각 토스트별 타이머(닫힘 트리거/제거 타이머)를 관리 +type Timers = { closeTimer?: number; removeTimer?: number }; + +export const ToastProvider = ({ children }: { children: ReactNode }) => { const [visible, setVisible] = useState([]); - const [, setQueue] = useState([]); - const idRef = useRef(0); - const timersRef = useRef>(new Map()); - - const startTimer = useCallback((toast: ToastItem) => { - const timer = window.setTimeout(() => { - // 자동 닫힘 - setVisible((prev) => prev.filter((t) => t.id !== toast.id)); - timersRef.current.delete(toast.id); - - // 빈 칸 생기면 큐에서 하나 꺼내기 - setQueue((q) => { - if (q.length === 0) return q; - const [next, ...rest] = q; - setVisible((prev) => { - const updated = [...prev, next]; - // 다음 토스트 타이머 시작 - startTimer(next); - return updated; - }); - return rest; - }); - }, toast.duration); - timersRef.current.set(toast.id, timer); + const [, setQueue] = useState([]); // 후순위 대기열(읽지 않아도 됨) + const timersRef = useRef>(new Map()); + + const clearTimers = useCallback((id: string) => { + const t = timersRef.current.get(id); + if (!t) return; + if (t.closeTimer) clearTimeout(t.closeTimer); + if (t.removeTimer) clearTimeout(t.removeTimer); + timersRef.current.delete(id); }, []); + // duration 이후 closing=true, 이후 ANIMATION_DURATION 지나면 실제 제거 + 큐 승격 + const startTimers = useCallback( + (toast: ToastItem) => { + clearTimers(toast.id); + + const closeTimer = window.setTimeout(() => { + // 내려가는 애니메이션 시작 + setVisible((prev) => prev.map((t) => (t.id === toast.id ? { ...t, closing: true } : t))); + + const removeTimer = window.setTimeout(() => { + // 화면에서 제거 + setVisible((prev) => prev.filter((t) => t.id !== toast.id)); + timersRef.current.delete(toast.id); + + // 큐 승격 + setQueue((q) => { + if (q.length === 0) return q; + const [next, ...rest] = q; + setVisible((prev) => { + // 이미 들어있으면 중복 추가 방지 + if (prev.some((p) => p.id === next.id)) return prev; + startTimers(next); + return [...prev, next]; + }); + return rest; + }); + }, ANIMATION_DURATION); + + const prev = timersRef.current.get(toast.id) ?? {}; + timersRef.current.set(toast.id, { ...prev, removeTimer }); + }, toast.duration); + + const prev = timersRef.current.get(toast.id) ?? {}; + timersRef.current.set(toast.id, { ...prev, closeTimer }); + }, + [clearTimers] + ); + const dismissToast = useCallback( - (id: number) => { - const timer = timersRef.current.get(id); - if (timer) { - clearTimeout(timer); + (id: string) => { + // 즉시 closing -> ANIMATION_DURATION 뒤 제거 + clearTimers(id); + + // 이미 closing이면 중복 처리 방지 + setVisible((prev) => { + const target = prev.find((t) => t.id === id); + if (!target) return prev; + if (target.closing) return prev; + return prev.map((t) => (t.id === id ? { ...t, closing: true } : t)); + }); + + const removeTimer = window.setTimeout(() => { + setVisible((prev) => prev.filter((t) => t.id !== id)); timersRef.current.delete(id); - } - setVisible((prev) => prev.filter((t) => t.id !== id)); - // 닫히면 빈 칸 채우기 - setQueue((q) => { - if (q.length === 0) return q; - const [next, ...rest] = q; - setVisible((prev) => { - if (prev.length >= MAX_VISIBLE) return prev; - startTimer(next); - return [...prev, next]; + // 큐 승격 + setQueue((q) => { + if (q.length === 0) return q; + const [next, ...rest] = q; + setVisible((prev) => { + if (prev.some((p) => p.id === next.id)) return prev; + if (prev.length >= MAX_VISIBLE) return prev; + startTimers(next); + return [...prev, next]; + }); + return rest; }); - return rest; - }); + }, ANIMATION_DURATION); + + const prev = timersRef.current.get(id) ?? {}; + timersRef.current.set(id, { ...prev, removeTimer }); }, - [startTimer] + [clearTimers, startTimers] ); const showToast = useCallback( (args: ShowToastArguments) => { const item: ToastItem = { - id: ++idRef.current, + id: nextToastId(), title: args.title ?? '알림', contents: args.contents, key: args.key, duration: args.duration ?? DEFAULT_DURATION, + closing: false, }; - // 1) key 중복합치기: visible/queue에서 같은 key를 찾아 갱신 + 타이머 리셋 + // 1) key 중복합치기 (visible/queue) if (item.key) { let updated = false; setVisible((prev) => { - const index = prev.findIndex((t) => t.key === item.key); - if (index >= 0) { - const old = prev[index]; - // 타이머 리셋 - const h = timersRef.current.get(old.id); - if (h) { - clearTimeout(h); - timersRef.current.delete(old.id); - } + const idx = prev.findIndex((t) => t.key === item.key); + if (idx >= 0) { + const old = prev[idx]; + clearTimers(old.id); const merged: ToastItem = { ...old, title: item.title, contents: item.contents, duration: item.duration, + closing: false, // 재활성화 }; - startTimer(merged); + startTimers(merged); const clone = prev.slice(); - clone[index] = merged; + clone[idx] = merged; updated = true; return clone; } @@ -134,11 +178,11 @@ export const ToastProvider = ({ children }: ToastProviderProps) => { if (!updated) { setQueue((prev) => { - const index = prev.findIndex((t) => t.key === item.key); - if (index >= 0) { + const idx = prev.findIndex((t) => t.key === item.key); + if (idx >= 0) { const clone = prev.slice(); - clone[index] = { - ...clone[index], + clone[idx] = { + ...clone[idx], title: item.title, contents: item.contents, duration: item.duration, @@ -150,27 +194,29 @@ export const ToastProvider = ({ children }: ToastProviderProps) => { }); } - if (updated) return; // 이미 갱신됐으면 여기서 끝 + if (updated) return; } - // 2) 자리가 있으면 visible에 올리고 타이머 시작 + // 2) 빈 자리면 visible + 타이머 시작, 아니면 큐 쌓기 setVisible((prev) => { if (prev.length < MAX_VISIBLE) { - startTimer(item); + startTimers(item); return [...prev, item]; } - // 3) 아니면 큐에 적재 setQueue((q) => [...q, item]); return prev; }); }, - [startTimer] + [startTimers, clearTimers] ); // 언마운트 시 모든 타이머 정리 useEffect(() => { return () => { - timersRef.current.forEach((h) => clearTimeout(h)); + timersRef.current.forEach(({ closeTimer, removeTimer }) => { + if (closeTimer) clearTimeout(closeTimer); + if (removeTimer) clearTimeout(removeTimer); + }); timersRef.current.clear(); }; }, []); diff --git a/src/components/Toast/ToastViewport.tsx b/src/components/Toast/ToastViewport.tsx index ee66c1d8..fb603cfb 100644 --- a/src/components/Toast/ToastViewport.tsx +++ b/src/components/Toast/ToastViewport.tsx @@ -1,8 +1,33 @@ import { createPortal } from 'react-dom'; -import { useToast } from './ToastProvider'; +import { ANIMATION_DURATION, useToast } from './ToastProvider'; +import { useEffect, useMemo, useState } from 'react'; const ToastViewport = () => { const { visible } = useToast(); + const [mountedIds, setMountedIds] = useState>(new Set()); + + // 새로 생긴 토스트 나타나는 애니메이션 트리거 + useEffect(() => { + const current = new Set(visible.map((t) => t.id)); + const news = [...current].filter((id) => !mountedIds.has(id)); + if (news.length) { + const r = requestAnimationFrame(() => { + setMountedIds((prev) => new Set([...prev, ...news])); + }); + return () => cancelAnimationFrame(r); + } + // 사라진 토스트 id는 정리(메모리 관리) + const gone = [...mountedIds].filter((id) => !current.has(id)); + if (gone.length) { + setMountedIds((prev) => { + const next = new Set(prev); + gone.forEach((id) => next.delete(id)); + return next; + }); + } + }, [visible, mountedIds]); + + const items = useMemo(() => visible, [visible]); return createPortal(
{ aria-live="polite" // 텍스트 업데이트 시 대기 후 스크린리더가 읽음 aria-atomic="true" // 메시지 전체를 한 번에 읽게 함 > - {visible.map((t) => ( -
{ - e.stopPropagation(); - }} - onClick={(e) => { - e.stopPropagation(); - }} - > -

{t.title}

-

{t.contents}

-
- ))} + {items.map((t) => { + const mounted = mountedIds.has(t.id); + const leaving = !!t.closing; + + // 기본 스타일 + 트랜지션 + const base = + 'pointer-events-auto flex flex-col gap-[1.6rem] p-[2.4rem] rounded-lg border bg-gray-200 border-gray-300 text-gray-600 transform transition-all ease-in-out'; + const motionClass = leaving + ? 'opacity-0 translate-y-3' // 토스트 사라지는 애니메이션 (아래로 사라짐) + : mounted + ? 'opacity-100 translate-y-0' // 토스트 나타남 완료/유지 + : 'opacity-0 translate-y-3'; // mount 직후 프레임 + + return ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +

{t.title}

+
{t.contents}
+
+ ); + })}
, document.body ); From 74f83ab049518b5c5f8cd80327f1a21d3bd6d856 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 22:29:18 +0900 Subject: [PATCH 08/12] =?UTF-8?q?#66=20[FEAT]=20=EC=A0=9C=EB=AA=A9=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=EC=88=98=2020=EC=9E=90=20=EC=A0=9C=ED=95=9C?= =?UTF-8?q?=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DetailView/DetailTitle.tsx | 74 ++++++++++++++++++++--- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/src/components/DetailView/DetailTitle.tsx b/src/components/DetailView/DetailTitle.tsx index 5b953315..b92cc21e 100644 --- a/src/components/DetailView/DetailTitle.tsx +++ b/src/components/DetailView/DetailTitle.tsx @@ -2,17 +2,23 @@ // 상세페이지 제목 컴포넌트 import { useEffect, useRef } from 'react'; +import { useToast } from '../Toast/ToastProvider'; interface DetailTitleProps { defaultTitle: string; title: string; setTitle: (value: string) => void; - isEditable: boolean; // true일 때만 상세 설명 내용 입력 가능함 } +const MAX_TITLE = 20; // 최대 제목 글자 수 + const DetailTitle = ({ defaultTitle, title, setTitle, isEditable }: DetailTitleProps) => { const textarea = useRef(null); + const committedLenRef = useRef(title.length); // 마지막으로 커밋된(클램프 반영된) 길이 + const warnedRef = useRef(false); // 이번 글자수 초과 구간에서 토스트를 이미 띄웠는지 확인 + const composingRef = useRef(false); // 입력 방식 편집기(IME) 사용 중인지 + const { showToast } = useToast(); const handleResizeHeight = () => { if (textarea.current) { @@ -21,23 +27,71 @@ const DetailTitle = ({ defaultTitle, title, setTitle, isEditable }: DetailTitleP } }; - // 입력 시 + const clamp = (s: string) => (s.length > MAX_TITLE ? s.slice(0, MAX_TITLE) : s); + + // 길이 상태 평가 + (필요 시) 토스트 1회 + const evaluateLimitAndMaybeToast = (nextRaw: string) => { + const isOver = nextRaw.length > MAX_TITLE; + const wasUnderOrEqual = committedLenRef.current <= MAX_TITLE; + + // 20자 이하로 돌아오면 다음 초과 때 다시 토스트 허용 + if (!isOver) warnedRef.current = false; + + if (wasUnderOrEqual && isOver && !warnedRef.current) { + showToast({ + contents: '최대 20자까지 작성할 수 있습니다.', + key: 'titleMax', // 중복 합치기 + }); + warnedRef.current = true; + } + }; + + // onChange: 모든 입력 변경(키보드, 붙여넣기, 마우스 등)에서 호출됨 const handleChange = (e: React.ChangeEvent) => { - setTitle(e.target.value); + const nextRaw = e.target.value; + + // IME 조합 중에는 토스트/클램프를 지연하고, 조합 종료에서 한 번에 처리 + if (composingRef.current) { + setTitle(nextRaw); + handleResizeHeight(); + return; + } + + // 조합이 아니면 즉시 검사 및 토스트 + evaluateLimitAndMaybeToast(nextRaw); + + // 조합이 아니면 즉시 평가 -> 토스트 -> 클램프 -> 커밋 + evaluateLimitAndMaybeToast(nextRaw); + const next = clamp(nextRaw); + setTitle(next); + committedLenRef.current = next.length; + handleResizeHeight(); + }; + + // IME 조합 시작/종료 + const handleCompositionStart = () => { + composingRef.current = true; + }; + const handleCompositionEnd = (e: React.CompositionEvent) => { + composingRef.current = false; + const nextRaw = e.currentTarget.value; + + // 조합 종료 시 한 번만 평가 -> 토스트 -> 클램프 -> 커밋 + evaluateLimitAndMaybeToast(nextRaw); + const next = clamp(nextRaw); + setTitle(next); + committedLenRef.current = next.length; handleResizeHeight(); }; - // 초기 mount 및 title 업데이트 시 제목 textarea 높이 조절되게 useEffect(() => { handleResizeHeight(); }, [title]); - // 뷰포트 사이즈 변경 시에도 제목 textarea 높이 재조정 useEffect(() => { - window.addEventListener('resize', handleResizeHeight); - return () => { - window.removeEventListener('resize', handleResizeHeight); - }; + const r = () => handleResizeHeight(); + window.addEventListener('resize', r); + return () => window.removeEventListener('resize', r); }, []); return ( @@ -48,6 +102,8 @@ const DetailTitle = ({ defaultTitle, title, setTitle, isEditable }: DetailTitleP value={title} rows={1} onChange={handleChange} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); // 엔터 키를 눌러 직접 줄바꿈하는 것 방지 From 58d92b0243b587e40cae254d07a9fe55ef38fdc2 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 23:09:04 +0900 Subject: [PATCH 09/12] =?UTF-8?q?#66=20[FEAT]=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=ED=88=B4=20=EA=B4=80=EB=A0=A8=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/external/ExternalDetail.tsx | 60 +++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/src/pages/external/ExternalDetail.tsx b/src/pages/external/ExternalDetail.tsx index dee708df..94a2397f 100644 --- a/src/pages/external/ExternalDetail.tsx +++ b/src/pages/external/ExternalDetail.tsx @@ -65,6 +65,7 @@ import { getGithubInstallationId, useGetGithubInstallationId, } from '../../apis/external/useGetGithubInstallationId.ts'; +import { useToast } from '../../components/Toast/ToastProvider.tsx'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -119,6 +120,8 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const { showToast } = useToast(); // 우측 하단 토스트 + const canChangeExternal = mode === 'create'; // 외부 항목 편집 가능 여부 (create 모드일 때만 가능) const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) @@ -212,18 +215,31 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { console.log('Request body:', basePayload); if (mode === 'create') { - // 1) GitHub 선택 시 필수값 검증 + // 1) 외부 툴 선택이 안 되었을 때 + if (!extServiceType) { + isSubmittingRequestRef.current = false; + showToast({ + contents: '반드시 외부 툴을 설정해야 합니다.', + key: 'extRequired', // 중복 합치기 + }); + return; // 생성 중단 + } + + // 2) GitHub 선택 시 필수값 검증 if (extServiceType === 'GITHUB') { if (repoLoading || installLoading) { isSubmittingRequestRef.current = false; - alert('GitHub 정보를 불러오는 중입니다. 잠시만요!'); + showToast({ contents: 'GitHub 정보를 불러오는 중입니다.', key: 'githubLoading' }); return; } - // 2) 값이 준비되지 않았으면 중단 + // 3) 값이 준비되지 않았으면 중단 if (!isGithubReady) { isSubmittingRequestRef.current = false; console.error('GitHub 연동 누락:', githubPayload); - alert('GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.'); + showToast({ + contents: 'GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.', + key: 'githubMissing', + }); return; } const { owner, repo, installationId } = githubPayload; @@ -234,6 +250,11 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { repo, installationId, }); + showToast({ + contents: 'GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.', + key: 'githubMissing', + }); + return; } } @@ -290,12 +311,16 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { } }; - // handleCompletion - 하단 작성 완료<-수정하기 버튼 클릭 시 실행 - // - create/edit → view: API 저장 후 모드 전환 - // - view → edit: API 호출 없이 모드 전환 + // handleCompletion - 하단 작성 완료<->수정하기 버튼 클릭 시 실행 + // - create/edit -> view: API 저장 후 모드 전환 + // - view -> edit: API 호출 없이 모드 전환 const handleCompletion = () => { if (!isCompleted) { // create 또는 edit 모드에서 view 모드로 전환하려는 시점 + if (mode === 'create' && !extServiceType) { + showToast({ contents: '반드시 외부 툴을 설정해야 합니다.', key: 'extRequired' }); + return; + } handleSubmit(); // 저장 성공 시 모드 전환 } else { handleToggleMode(); // 모드 전환 @@ -571,13 +596,14 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => {
{/* (6) 외부 */} -
e.stopPropagation()}> +
e.stopPropagation()} className="relative"> { + if (!canChangeExternal) return; // 가드 const code = LABEL_TO_EXTERNAL_CODE[label]; setExtServiceType(code ?? null); if (code === 'GITHUB') { @@ -592,6 +618,24 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { } }} /> + + {/* view / edit 모드에서는 클릭 완전 차단 */} + {!canChangeExternal && ( +
e.preventDefault()} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + showToast({ + contents: '외부 툴은 생성 시 한 번만 설정 가능합니다.', + key: 'extLocked', + }); + }} + role="button" + aria-label="외부 툴은 생성 시 한 번만 설정 가능합니다." + /> + )}
From 6b625f08776ede8d3bffacc5483952c546318780 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Thu, 21 Aug 2025 23:51:51 +0900 Subject: [PATCH 10/12] =?UTF-8?q?#66=20[FEAT]=20=EB=AA=A9=ED=91=9C=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EC=A4=91=EC=A7=80=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=82=98=EA=B0=88=20=EB=95=8C=20=EB=B8=94=EB=A1=9C=ED=82=B9=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EB=9C=A8=EA=B2=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/goal/GoalDetail.tsx | 72 +++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/src/pages/goal/GoalDetail.tsx b/src/pages/goal/GoalDetail.tsx index 3fa98c9a..8a967afe 100644 --- a/src/pages/goal/GoalDetail.tsx +++ b/src/pages/goal/GoalDetail.tsx @@ -1,7 +1,7 @@ // GoalDetail.tsx // 목표 상세페이지 -import { useState, useRef, startTransition } from 'react'; +import { useState, useRef, startTransition, useEffect } from 'react'; import DetailHeader from '../../components/DetailView/DetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -31,7 +31,7 @@ import { } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; import type { CreateGoalDetailDto, UpdateGoalDetailDto } from '../../types/goal'; import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { useIsMutating } from '@tanstack/react-query'; import { mutationKey } from '../../constants/mutationKey'; import MultiSelectPropertyItem from '../../components/DetailView/MultiSelectPropertyItem'; @@ -51,6 +51,8 @@ import { useUpdateGoal } from '../../apis/goal/usePatchGoalDetail'; import { useGoalDeadlinePatch } from '../../hooks/useGoalDeadlinePatch'; import { queryKey } from '../../constants/queryKey'; import queryClient from '../../utils/queryClient'; +import { useModalActions, useModalInfo } from '../../hooks/useModal'; +import Modal from '../../components/Modal/Modal'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -88,12 +90,17 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.GOAL_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericGoalId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; @@ -113,6 +120,40 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { const [managersShowNoneLabel] = useState(false); const [issuesShowNoneLabel, setIssuesShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked' && !isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useGoalDeadlinePatch({ goalDetail, @@ -417,7 +458,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -474,6 +515,29 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; From c21892b16f96cf934afb0f18a899e64cf97173e8 Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 22 Aug 2025 00:08:07 +0900 Subject: [PATCH 11/12] =?UTF-8?q?#66=20[FEAT]=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EC=A4=91=EC=A7=80=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=82=98=EA=B0=88=20=EB=95=8C=20=EB=B8=94=EB=A1=9C=ED=82=B9=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EB=9C=A8=EA=B2=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/external/ExternalDetail.tsx | 75 +++++++++- src/pages/goal/GoalDetail.tsx | 6 +- src/pages/issue/IssueDetail.tsx | 74 +++++++++- .../workspace/WorkspaceExternalDetail.tsx | 130 ++++++++++++++++-- src/pages/workspace/WorkspaceGoalDetail.tsx | 73 +++++++++- src/pages/workspace/WorkspaceIssueDetail.tsx | 74 +++++++++- 6 files changed, 400 insertions(+), 32 deletions(-) diff --git a/src/pages/external/ExternalDetail.tsx b/src/pages/external/ExternalDetail.tsx index 94a2397f..0fb53d2e 100644 --- a/src/pages/external/ExternalDetail.tsx +++ b/src/pages/external/ExternalDetail.tsx @@ -1,7 +1,7 @@ // ExternalDetail.tsx // 외부 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import DetailHeader from '../../components/DetailView/DetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -26,7 +26,7 @@ import CalendarDropdown from '../../components/Calendar/CalendarDropdown'; import { useDropdownActions, useDropdownInfo } from '../../hooks/useDropdown'; import { formatDateDot, formatDateHyphen } from '../../utils/formatDate'; import { useToggleMode } from '../../hooks/useToggleMode'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { useGetExternalLinks } from '../../apis/external/useGetExternalLinks.ts'; import CommentInput from '../../components/DetailView/Comment/CommentInput'; @@ -66,6 +66,8 @@ import { useGetGithubInstallationId, } from '../../apis/external/useGetGithubInstallationId.ts'; import { useToast } from '../../components/Toast/ToastProvider.tsx'; +import { useModalActions, useModalInfo } from '../../hooks/useModal.ts'; +import Modal from '../../components/Modal/Modal.tsx'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -118,14 +120,20 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { useIsMutating({ mutationKey: [mutationKey.EXTERNAL_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) - const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); + const { showToast } = useToast(); // 우측 하단 토스트 const canChangeExternal = mode === 'create'; // 외부 항목 편집 가능 여부 (create 모드일 때만 가능) const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericExternalId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 const repoObj = useMemo( () => (Array.isArray(githubRepo) ? githubRepo[0] : githubRepo), @@ -171,6 +179,40 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked' && !isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useExternalDeadlinePatch({ externalDetail, @@ -555,7 +597,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -650,6 +692,29 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; diff --git a/src/pages/goal/GoalDetail.tsx b/src/pages/goal/GoalDetail.tsx index 8a967afe..1a4d7b2f 100644 --- a/src/pages/goal/GoalDetail.tsx +++ b/src/pages/goal/GoalDetail.tsx @@ -100,7 +100,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericGoalId); // PATCH 가능 조건 - const blocker = useBlocker(isEditable); + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; @@ -259,7 +259,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { 우선순위: pr3, 없음: pr0, 낮음: pr1, - 보통: pr2, + 중간: pr2, 높음: pr3, 긴급: pr4, }; @@ -395,7 +395,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => {
e.stopPropagation()}> { const next = priorityLabelToCode[label] ?? 'NONE'; diff --git a/src/pages/issue/IssueDetail.tsx b/src/pages/issue/IssueDetail.tsx index a8f16a4b..b3a188d8 100644 --- a/src/pages/issue/IssueDetail.tsx +++ b/src/pages/issue/IssueDetail.tsx @@ -1,7 +1,7 @@ // IssueDetail.tsx // 이슈 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import DetailHeader from '../../components/DetailView/DetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -35,7 +35,7 @@ import { } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; import { useIsMutating } from '@tanstack/react-query'; import { mutationKey } from '../../constants/mutationKey'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { PRIORITY_LABELS, STATUS_LABELS, @@ -52,6 +52,8 @@ import type { CreateIssueDetailDto, UpdateIssueDetailDto } from '../../types/iss import queryClient from '../../utils/queryClient.ts'; import { queryKey } from '../../constants/queryKey.ts'; import { useHydrateIssueDetail } from '../../hooks/useHydrateIssueDetail.ts'; +import { useModalActions, useModalInfo } from '../../hooks/useModal.ts'; +import Modal from '../../components/Modal/Modal.tsx'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -92,12 +94,17 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.ISSUE_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) - const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericIssueId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; @@ -116,6 +123,40 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked' && !isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useIssueDeadlinePatch({ issueDetail, @@ -435,7 +476,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -487,6 +528,29 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; diff --git a/src/pages/workspace/WorkspaceExternalDetail.tsx b/src/pages/workspace/WorkspaceExternalDetail.tsx index 8832e73d..4f68b80e 100644 --- a/src/pages/workspace/WorkspaceExternalDetail.tsx +++ b/src/pages/workspace/WorkspaceExternalDetail.tsx @@ -1,7 +1,7 @@ // WorkspaceExternalDetail.tsx // 워크스페이스 전체 팀 - 외부 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import WorkspaceDetailHeader from '../../components/DetailView/WorkspaceDetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -26,7 +26,7 @@ import CalendarDropdown from '../../components/Calendar/CalendarDropdown'; import { useDropdownActions, useDropdownInfo } from '../../hooks/useDropdown'; import { formatDateDot, formatDateHyphen } from '../../utils/formatDate'; import { useToggleMode } from '../../hooks/useToggleMode'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { useGetExternalLinks } from '../../apis/external/useGetExternalLinks.ts'; import { usePostComment } from '../../apis/comment/usePostComment'; @@ -65,6 +65,9 @@ import type { CreateExternalDetailDto, UpdateExternalDetailDto } from '../../typ import queryClient from '../../utils/queryClient.ts'; import { queryKey } from '../../constants/queryKey.ts'; import { useHydrateExternalDetail } from '../../hooks/useHydrateExternalDetail.ts'; +import { useModalActions, useModalInfo } from '../../hooks/useModal.ts'; +import { useToast } from '../../components/Toast/ToastProvider.tsx'; +import Modal from '../../components/Modal/Modal.tsx'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -117,12 +120,20 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) useIsMutating({ mutationKey: [mutationKey.EXTERNAL_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) - const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); + + const { showToast } = useToast(); // 우측 하단 토스트 + const canChangeExternal = mode === 'create'; // 외부 항목 편집 가능 여부 (create 모드일 때만 가능) const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericExternalId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 const repoObj = useMemo( () => (Array.isArray(githubRepo) ? githubRepo[0] : githubRepo), @@ -168,6 +179,40 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked' && !isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useExternalDeadlinePatch({ externalDetail, @@ -212,18 +257,32 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) console.log('Request body:', basePayload); if (mode === 'create') { - // 1) GitHub 선택 시 필수값 검증 + // 1) 외부 툴 선택이 안 되었을 때 + if (!extServiceType) { + isSubmittingRequestRef.current = false; + showToast({ + contents: '반드시 외부 툴을 설정해야 합니다.', + key: 'extRequired', // 중복 합치기 + }); + return; // 생성 중단 + } + + // 2) GitHub 선택 시 필수값 검증 if (extServiceType === 'GITHUB') { if (repoLoading || installLoading) { isSubmittingRequestRef.current = false; - alert('GitHub 정보를 불러오는 중입니다. 잠시만요!'); + showToast({ contents: 'GitHub 정보를 불러오는 중입니다.', key: 'githubLoading' }); return; } - // 2) 값이 준비되지 않았으면 중단 + + // 3) 값이 준비되지 않았으면 중단 if (!isGithubReady) { isSubmittingRequestRef.current = false; console.error('GitHub 연동 누락:', githubPayload); - alert('GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.'); + showToast({ + contents: 'GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.', + key: 'githubMissing', + }); return; } const { owner, repo, installationId } = githubPayload; @@ -234,6 +293,11 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) repo, installationId, }); + showToast({ + contents: 'GitHub 연동 정보가 부족합니다. 설치/온보딩을 먼저 완료해 주세요.', + key: 'githubMissing', + }); + return; } } @@ -296,6 +360,10 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) const handleCompletion = () => { if (!isCompleted) { // create 또는 edit 모드에서 view 모드로 전환하려는 시점 + if (mode === 'create' && !extServiceType) { + showToast({ contents: '반드시 외부 툴을 설정해야 합니다.', key: 'extRequired' }); + return; + } handleSubmit(); // 저장 성공 시 모드 전환 } else { handleToggleMode(); // 모드 전환 @@ -530,7 +598,7 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -571,13 +639,14 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) {/* (6) 외부 */} -
e.stopPropagation()}> +
e.stopPropagation()} className="relative"> { + if (!canChangeExternal) return; // 가드 const code = LABEL_TO_EXTERNAL_CODE[label]; setExtServiceType(code ?? null); if (code === 'GITHUB') { @@ -592,6 +661,24 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) } }} /> + + {/* view / edit 모드에서는 클릭 완전 차단 */} + {!canChangeExternal && ( +
e.preventDefault()} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + showToast({ + contents: '외부 툴은 생성 시 한 번만 설정 가능합니다.', + key: 'extLocked', + }); + }} + role="button" + aria-label="외부 툴은 생성 시 한 번만 설정 가능합니다." + /> + )}
@@ -606,6 +693,29 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps)
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; diff --git a/src/pages/workspace/WorkspaceGoalDetail.tsx b/src/pages/workspace/WorkspaceGoalDetail.tsx index a32382e1..6ed82b6e 100644 --- a/src/pages/workspace/WorkspaceGoalDetail.tsx +++ b/src/pages/workspace/WorkspaceGoalDetail.tsx @@ -1,7 +1,7 @@ // WorkspaceGoalDetail.tsx // 워크스페이스 전체 팀 - 목표 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import WorkspaceDetailHeader from '../../components/DetailView/WorkspaceDetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -38,7 +38,7 @@ import { EMPTY_EDITOR_STATE, type SubmitHandleRef, } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { useGetWorkspaceMembers } from '../../apis/setting/useGetWorkspaceMembers'; import { useGetSimpleIssueList } from '../../apis/issue/useGetSimpleIssueList'; import { useCreateGoal } from '../../apis/goal/usePostCreateGoalDetail'; @@ -51,6 +51,8 @@ import { useUpdateGoal } from '../../apis/goal/usePatchGoalDetail'; import { useGoalDeadlinePatch } from '../../hooks/useGoalDeadlinePatch'; import queryClient from '../../utils/queryClient'; import { queryKey } from '../../constants/queryKey'; +import { useModalActions, useModalInfo } from '../../hooks/useModal'; +import Modal from '../../components/Modal/Modal'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -88,12 +90,17 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.GOAL_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericGoalId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; @@ -113,6 +120,40 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { const [managersShowNoneLabel] = useState(false); const [issuesShowNoneLabel, setIssuesShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked' && !isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useGoalDeadlinePatch({ goalDetail, @@ -423,7 +464,7 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -443,6 +484,7 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { onChange={(labels) => { if (labels.includes('없음')) { setIssuesId([]); + setIssuesShowNoneLabel(true); if (isCompleted && Number.isFinite(numericGoalId)) { updateGoal({ issuesId: [] }); } @@ -479,6 +521,29 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; diff --git a/src/pages/workspace/WorkspaceIssueDetail.tsx b/src/pages/workspace/WorkspaceIssueDetail.tsx index a3ba82b3..644a425a 100644 --- a/src/pages/workspace/WorkspaceIssueDetail.tsx +++ b/src/pages/workspace/WorkspaceIssueDetail.tsx @@ -1,7 +1,7 @@ // WorkspaceIssueDetail.tsx // 워크스페이스 전체 팀 - 이슈 상세페이지 -import { useState, useRef, useMemo, startTransition } from 'react'; +import { useState, useRef, useMemo, startTransition, useEffect } from 'react'; import WorkspaceDetailHeader from '../../components/DetailView/WorkspaceDetailHeader'; import PropertyItem from '../../components/DetailView/PropertyItem'; import DetailTitle from '../../components/DetailView/DetailTitle'; @@ -33,7 +33,7 @@ import { EMPTY_EDITOR_STATE, type SubmitHandleRef, } from '../../components/DetailView/TextEditor/lexical-plugins/SubmitHandlePlugin'; -import { useParams } from 'react-router-dom'; +import { useBlocker, useParams } from 'react-router-dom'; import { mutationKey } from '../../constants/mutationKey'; import { useIsMutating } from '@tanstack/react-query'; import { @@ -52,6 +52,8 @@ import type { CreateIssueDetailDto, UpdateIssueDetailDto } from '../../types/iss import { queryKey } from '../../constants/queryKey.ts'; import queryClient from '../../utils/queryClient.ts'; import { useHydrateIssueDetail } from '../../hooks/useHydrateIssueDetail.ts'; +import { useModalActions, useModalInfo } from '../../hooks/useModal.ts'; +import Modal from '../../components/Modal/Modal.tsx'; /** 상세페이지 모드 구분 * (1) create - 생성 모드: 처음에 생성하여 작성 완료하기 전 @@ -92,12 +94,17 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { const isCreatingGlobal = useIsMutating({ mutationKey: [mutationKey.ISSUE_CREATE, teamId] }) > 0; const isSaving = isCreating || isUpdating || isCreatingGlobal || isSubmittingRequestRef.current; - const { isOpen, content } = useDropdownInfo(); // 작성 완료 여부 (view 모드일 때 true) - const { openDropdown } = useDropdownActions(); // 수정 가능 여부 (create 또는 edit 모드일 때 true) + const { isOpen: isDropdownOpen, content: dropdownContent } = useDropdownInfo(); // 현재 드롭다운의 열림 여부와 내용 가져옴 + const { openDropdown } = useDropdownActions(); + const { openModal, closeModal } = useModalActions(); + const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); + const confirmedRef = useRef(false); + const prevOpenRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) const canPatch = Number.isFinite(numericIssueId); // PATCH 가능 조건 + const blocker = useBlocker(isEditable); // 편집하고 있는 상황에 화면 이동을 블로킹 // 단일 선택 라벨 const selectedStatusLabel = STATUS_LABELS[state]; @@ -116,6 +123,40 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { }, [managersId, workspaceMembers]); const [managersShowNoneLabel] = useState(false); + useEffect(() => { + if (!isEditable) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isEditable]); + + // 라우팅이 막히면 모달 오픈 + useEffect(() => { + if (blocker.state === 'blocked' && !isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } + }, [blocker.state, isModalOpen, openModal]); + + // 모달이 닫힐 때: 확인 누른 게 아니면 reset() + useEffect(() => { + if (prevOpenRef.current && !isModalOpen) { + if (blocker.state === 'blocked' && !confirmedRef.current) { + blocker.reset(); + } + confirmedRef.current = false; + } + prevOpenRef.current = isModalOpen; + }, [isModalOpen, blocker.state]); + + // 다른 화면으로 나갈 때 남아있던 모달이 따라오지 않도록 정리 + useEffect(() => { + return () => { + closeModal(); + }; + }, [closeModal]); + // deadline('기한' 속성) patch 훅 const { handleSelectDateAndPatch, buildPatchForEditSubmit } = useIssueDeadlinePatch({ issueDetail, @@ -435,7 +476,7 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { {/* '기한' 항목명 - 날짜 설정하면 반영됨 */} {getDisplayText()} {/* 달력 드롭다운 오픈 */} - {isOpen && content?.name === 'date' && ( + {isDropdownOpen && dropdownContent?.name === 'date' && ( { @@ -487,6 +528,29 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => {
+ + {isModalOpen && modalContent?.name === 'leaveConfirm' && blocker?.state === 'blocked' && ( + + 작성을 그만두시겠습니까? +
+ 작성중인 내용은 저장되지 않습니다. + + } + buttonText="확인" + buttonColor="bg-primary-blue" + onClick={() => { + confirmedRef.current = true; // 확인 눌렀음 표시 + closeModal(); + setTimeout(() => { + // 2) 다음 틱에 이동(레이스 방지) + blocker.proceed(); + }, 0); + }} + /> + )}
); }; From a18d345f764fbf0ef1a64f0a9f03055cc2164c2b Mon Sep 17 00:00:00 2001 From: HiJuwon Date: Fri, 22 Aug 2025 00:20:05 +0900 Subject: [PATCH 12/12] =?UTF-8?q?#66=20[MOD]=20=EB=82=B4=EB=B6=80=20mode?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20=EC=8B=9C=20=EB=B8=94=EB=A1=9C=ED=82=B9?= =?UTF-8?q?=20=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/external/ExternalDetail.tsx | 15 +++++++++++++-- src/pages/goal/GoalDetail.tsx | 15 +++++++++++++-- src/pages/issue/IssueDetail.tsx | 15 +++++++++++++-- src/pages/workspace/WorkspaceExternalDetail.tsx | 15 +++++++++++++-- src/pages/workspace/WorkspaceGoalDetail.tsx | 15 +++++++++++++-- src/pages/workspace/WorkspaceIssueDetail.tsx | 15 +++++++++++++-- 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/pages/external/ExternalDetail.tsx b/src/pages/external/ExternalDetail.tsx index 0fb53d2e..81b9f2da 100644 --- a/src/pages/external/ExternalDetail.tsx +++ b/src/pages/external/ExternalDetail.tsx @@ -126,6 +126,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); const confirmedRef = useRef(false); const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const { showToast } = useToast(); // 우측 하단 토스트 const canChangeExternal = mode === 'create'; // 외부 항목 편집 가능 여부 (create 모드일 때만 가능) @@ -190,8 +191,16 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { // 라우팅이 막히면 모달 오픈 useEffect(() => { - if (blocker.state === 'blocked' && !isModalOpen) { - openModal({ name: 'leaveConfirm' }); + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } } }, [blocker.state, isModalOpen, openModal]); @@ -321,6 +330,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { onSuccess: ({ externalId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(externalId)); }, onSettled: () => { @@ -346,6 +356,7 @@ const ExternalDetail = ({ initialMode }: ExternalDetailProps) => { queryKey: [queryKey.EXTERNAL_DETAIL, numericExternalId], }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => (isSubmittingRequestRef.current = false), diff --git a/src/pages/goal/GoalDetail.tsx b/src/pages/goal/GoalDetail.tsx index 1a4d7b2f..b95a1142 100644 --- a/src/pages/goal/GoalDetail.tsx +++ b/src/pages/goal/GoalDetail.tsx @@ -96,6 +96,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); const confirmedRef = useRef(false); const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) @@ -131,8 +132,16 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { // 라우팅이 막히면 모달 오픈 useEffect(() => { - if (blocker.state === 'blocked' && !isModalOpen) { - openModal({ name: 'leaveConfirm' }); + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } } }, [blocker.state, isModalOpen, openModal]); @@ -208,6 +217,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { onSuccess: ({ goalId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(goalId)); }, onSettled: () => { @@ -225,6 +235,7 @@ const GoalDetail = ({ initialMode }: GoalDetailProps) => { queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_DETAIL, numericGoalId] }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => { diff --git a/src/pages/issue/IssueDetail.tsx b/src/pages/issue/IssueDetail.tsx index b3a188d8..877e3d70 100644 --- a/src/pages/issue/IssueDetail.tsx +++ b/src/pages/issue/IssueDetail.tsx @@ -100,6 +100,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); const confirmedRef = useRef(false); const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) @@ -134,8 +135,16 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { // 라우팅이 막히면 모달 오픈 useEffect(() => { - if (blocker.state === 'blocked' && !isModalOpen) { - openModal({ name: 'leaveConfirm' }); + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } } }, [blocker.state, isModalOpen, openModal]); @@ -213,6 +222,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { onSuccess: ({ issueId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(issueId)); }, onSettled: () => { @@ -235,6 +245,7 @@ const IssueDetail = ({ initialMode }: IssueDetailProps) => { queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_DETAIL, numericIssueId] }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => { diff --git a/src/pages/workspace/WorkspaceExternalDetail.tsx b/src/pages/workspace/WorkspaceExternalDetail.tsx index 4f68b80e..e42e8b92 100644 --- a/src/pages/workspace/WorkspaceExternalDetail.tsx +++ b/src/pages/workspace/WorkspaceExternalDetail.tsx @@ -126,6 +126,7 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); const confirmedRef = useRef(false); const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const { showToast } = useToast(); // 우측 하단 토스트 const canChangeExternal = mode === 'create'; // 외부 항목 편집 가능 여부 (create 모드일 때만 가능) @@ -190,8 +191,16 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) // 라우팅이 막히면 모달 오픈 useEffect(() => { - if (blocker.state === 'blocked' && !isModalOpen) { - openModal({ name: 'leaveConfirm' }); + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } } }, [blocker.state, isModalOpen, openModal]); @@ -322,6 +331,7 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) onSuccess: ({ externalId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.EXTERNAL_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(externalId)); }, onSettled: () => { @@ -347,6 +357,7 @@ const WorkspaceExternalDetail = ({ initialMode }: WorkspaceExternalDetailProps) queryKey: [queryKey.EXTERNAL_DETAIL, numericExternalId], }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => (isSubmittingRequestRef.current = false), diff --git a/src/pages/workspace/WorkspaceGoalDetail.tsx b/src/pages/workspace/WorkspaceGoalDetail.tsx index 6ed82b6e..c0a8e427 100644 --- a/src/pages/workspace/WorkspaceGoalDetail.tsx +++ b/src/pages/workspace/WorkspaceGoalDetail.tsx @@ -96,6 +96,7 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); const confirmedRef = useRef(false); const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) @@ -131,8 +132,16 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { // 라우팅이 막히면 모달 오픈 useEffect(() => { - if (blocker.state === 'blocked' && !isModalOpen) { - openModal({ name: 'leaveConfirm' }); + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } } }, [blocker.state, isModalOpen, openModal]); @@ -208,6 +217,7 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { onSuccess: ({ goalId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(goalId)); }, onSettled: () => { @@ -225,6 +235,7 @@ const WorkspaceGoalDetail = ({ initialMode }: WorkspaceGoalDetailProps) => { queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_NAME, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.GOAL_DETAIL, numericGoalId] }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => { diff --git a/src/pages/workspace/WorkspaceIssueDetail.tsx b/src/pages/workspace/WorkspaceIssueDetail.tsx index 644a425a..9be35d68 100644 --- a/src/pages/workspace/WorkspaceIssueDetail.tsx +++ b/src/pages/workspace/WorkspaceIssueDetail.tsx @@ -100,6 +100,7 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { const { isOpen: isModalOpen, content: modalContent } = useModalInfo(); const confirmedRef = useRef(false); const prevOpenRef = useRef(false); + const suppressLeaveConfirmRef = useRef(false); const isCompleted = mode === 'view'; // 작성 완료 여부 (view 모드일 때 true) const isEditable = mode === 'create' || mode === 'edit'; // 수정 가능 여부 (create 또는 edit 모드일 때 true) @@ -134,8 +135,16 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { // 라우팅이 막히면 모달 오픈 useEffect(() => { - if (blocker.state === 'blocked' && !isModalOpen) { - openModal({ name: 'leaveConfirm' }); + if (blocker.state === 'blocked') { + if (suppressLeaveConfirmRef.current) { + // 내부 모드전환/내부 네비게이션: 모달 없이 바로 진행 + suppressLeaveConfirmRef.current = false; + blocker.proceed(); + return; + } + if (!isModalOpen) { + openModal({ name: 'leaveConfirm' }); + } } }, [blocker.state, isModalOpen, openModal]); @@ -213,6 +222,7 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { onSuccess: ({ issueId }) => { queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_LIST, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode(issueId)); }, onSettled: () => { @@ -235,6 +245,7 @@ const WorkspaceIssueDetail = ({ initialMode }: WorkspaceIssueDetailProps) => { queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_NAME, String(teamId)] }); queryClient.invalidateQueries({ queryKey: [queryKey.ISSUE_DETAIL, numericIssueId] }); } + suppressLeaveConfirmRef.current = true; // 내부 전환이므로 leaveConfirm 모달 억제 startTransition(() => handleToggleMode()); }, onSettled: () => {