Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@vanilla-extract/recipes": "^0.5.7",
"@vanilla-extract/vite-plugin": "^5.1.4",
"axios": "^1.13.2",
"es-hangul": "^2.3.8",
"eslint-import-resolver-typescript": "^4.4.4",
"framer-motion": "^12.27.1",
"react": "^19.2.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 43 additions & 0 deletions src/app/providers/modal-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect, useState, type ReactNode } from "react";
import { useLocation } from "react-router-dom";

import { modalStore } from "@/shared/model/store";

import { Modal } from "../../shared/ui/modal/modal";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

깊은 상대 경로 import는 alias로 통일해주세요.

이 파일은 Line 4에서 이미 alias import를 쓰고 있는데, ../../shared/ui/modal/modal만 상대 경로라 이동/리팩터링에 더 취약합니다. @/shared/ui/modal/modal로 맞추는 편이 일관성과 유지보수성에 좋습니다. As per coding guidelines, "@shared, @features, @widgets 등 alias 기반 절대 경로 import 사용 권장" and "깊은 상대 경로(../../..) 사용은 가독성과 유지보수 측면에서 지양".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/providers/modal-provider.tsx` at line 6, The file imports Modal using
a deep relative path ("../../shared/ui/modal/modal") which breaks consistency
with the other alias imports; replace that import with the project's alias
import (e.g., "@/shared/ui/modal/modal") in the modal-provider file where Modal
is imported so it matches the existing alias style, and ensure the import
statement referencing Modal is updated accordingly (no other changes required).


interface ModalItem {
id: string;
content: ReactNode;
autoPlay?: number;
}
Comment on lines +8 to +12
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

store의 ModalItem 타입을 재사용하는 편이 안전합니다.

여기 타입이 src/shared/model/store/modal.store.ts:3-8와 거의 동일해서, 필드가 추가되거나 바뀌면 provider 쪽만 조용히 drift할 수 있습니다. store에서 타입을 export하거나 별도 shared type으로 분리해 한 군데서 관리하는 편이 좋겠습니다. As per coding guidelines, "중복 코드 제거 및 재사용 가능한 구조 제안".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/providers/modal-provider.tsx` around lines 8 - 12, The local
ModalItem interface in modal-provider.tsx duplicates the store type; remove the
local declaration and import the canonical ModalItem type exported by the store
(the ModalItem exported from modal.store) so both the provider and store share a
single definition; update any uses in ModalProvider (e.g., state, props,
functions referencing ModalItem) to use the imported type and ensure the store
file exports ModalItem if it doesn't already.


export const ModalProvider = () => {
const { pathname } = useLocation();
const [modals, setModals] = useState<ModalItem[]>([]);

useEffect(() => {
modalStore.subscribe(setModals);
return () => modalStore.unsubscribe();
}, []);
Comment on lines +16 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

외부 store 구독은 useSyncExternalStore로 바꾸는 편이 더 견고합니다.

지금 패턴은 useState([])로 시작한 뒤 effect에서 subscribe만 등록하므로, 현재 snapshot 동기화와 concurrent rendering 안정성을 React가 직접 보장해주지 않습니다. modalStore가 외부 store라면 getSnapshot을 제공하고 useSyncExternalStore로 연결하는 쪽이 더 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/providers/modal-provider.tsx` around lines 16 - 21, The current modal
subscription uses useState + useEffect (modalStore.subscribe -> setModals) which
is not safe for concurrent rendering; replace this pattern by implementing a
getSnapshot function that reads current modalStore state and subscribe function
that delegates to modalStore.subscribe, then call React's
useSyncExternalStore(subscribe, getSnapshot) instead of useState/useEffect so
the component reads synchronized snapshots from modalStore; update references to
useSyncExternalStore, modalStore, and remove the useEffect/setModals logic
accordingly.


useEffect(() => {
modalStore.reset();
}, [pathname]);

return (
<>
{modals.map((modal) => {
return (
<Modal
key={modal.id}
isOpen={true}
autoPlay={modal.autoPlay}
onClose={() => modalStore.close(modal.id)}
>
{modal.content}
</Modal>
);
})}
</>
);
};
2 changes: 2 additions & 0 deletions src/app/routes/root-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { Outlet } from "react-router-dom";

import { Header } from "@widgets/index";

import { ModalProvider } from "../providers/modal-provider";
import { StoreResetListener } from "../providers/store-reset-listener";

export const RootLayout = () => {
return (
<>
<ModalProvider />
<StoreResetListener />
<Header />
<main>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect, useCallback, useRef } from "react";
import { useEffect, useCallback } from "react";
import { useBlocker } from "react-router-dom";

import { useModal } from "@/shared/ui/modal/use-modal";
import { modalStore } from "@/shared/model/store";
import { ModalBasic } from "@/shared/ui";

import {
initialDraft,
Expand All @@ -23,12 +24,12 @@ const isDraftDirty = (draft: ExperienceUpsertBody): boolean => {
);
};

const LEAVE_MODAL_ID = "leave-confirm-modal";

export const useLeaveConfirm = () => {
const mode = useExperienceDetailStore((s) => s.mode);
const draft = useExperienceDetailStore((s) => s.draft);

const { isOpen, openModal, closeModal } = useModal();

const shouldBlock =
(mode === "create" || mode === "edit") && isDraftDirty(draft);

Expand All @@ -46,17 +47,37 @@ export const useLeaveConfirm = () => {
return shouldBlockNow && !isSubmitting && !isTransitioning;
});

const prevBlockerStateRef = useRef(blocker.state);
const confirmLeave = useCallback(() => {
if (blocker.state === "blocked") {
blocker.proceed();
}
modalStore.close(LEAVE_MODAL_ID);
}, [blocker]);

useEffect(() => {
const wasBlocked = prevBlockerStateRef.current === "blocked";
const nowBlocked = blocker.state === "blocked";
prevBlockerStateRef.current = blocker.state;
const cancelLeave = useCallback(() => {
if (blocker.state === "blocked") {
blocker.reset();
}
modalStore.close(LEAVE_MODAL_ID);
}, [blocker]);

if (nowBlocked && !wasBlocked && !isOpen) {
openModal();
useEffect(() => {
if (blocker.state === "blocked") {
modalStore.open(
<ModalBasic
title={`작성중인 내용이 있습니다.\n정말 나가시겠습니까?`}
subTitle="저장하지 않으면 내용이 사라져요."
closeText="이어서 작성"
confirmText="나가기"
onClose={cancelLeave}
onConfirm={confirmLeave}
/>,
0,
undefined,
LEAVE_MODAL_ID
);
}
}, [blocker.state, isOpen, openModal]);
}, [blocker.state, cancelLeave, confirmLeave]);

useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
Expand All @@ -70,23 +91,5 @@ export const useLeaveConfirm = () => {
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [shouldBlock]);

const confirmLeave = useCallback(() => {
if (blocker.state === "blocked") {
blocker.proceed();
}
closeModal();
}, [blocker, closeModal]);

const cancelLeave = useCallback(() => {
if (blocker.state === "blocked") {
blocker.reset();
}
closeModal();
}, [blocker, closeModal]);

return {
isOpen,
confirmLeave,
cancelLeave,
};
return {};
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { EXPERIENCE_TYPE } from "@/shared/config/experience";
import { parseYMD } from "@/shared/lib/format-date";
import { modalStore } from "@/shared/model/store";
import { ModalBasic, Tooltip } from "@/shared/ui";
import { Button } from "@/shared/ui/button/button";
import { useModal } from "@/shared/ui/modal/use-modal";
import { Tag } from "@/shared/ui/tag/tag";
import { Textfield } from "@/shared/ui/textfield/textfield";
import { HELP_TOOLTIP_CONTENT } from "@/shared/ui/tooltip/tooltip.content";
Expand All @@ -24,9 +24,6 @@ const ExperienceViewer = () => {
const { showEditDelete, onClickEdit, onClickDelete, onToggleDefault } =
useExperienceHeaderActions();

const { isOpen: isDeleteModalOpen, handleModal: toggleDeleteModal } =
useModal();

const startDate = current?.startAt ? parseYMD(current.startAt) : null;
const endDate = current?.endAt ? parseYMD(current.endAt) : null;

Expand All @@ -42,6 +39,22 @@ const ExperienceViewer = () => {

const typeLabel = current.type ? EXPERIENCE_TYPE[current.type] : "미지정";

const handleOpenDeleteModal = () => {
modalStore.open(
<ModalBasic
title="이 경험을 삭제하시겠습니까?"
subTitle="작성한 내용은 즉시 제거되며, 복구할 수 없습니다."
closeText="취소"
confirmText="삭제"
onClose={() => modalStore.reset()} // 취소 시 닫기
onConfirm={() => {
onClickDelete(); // 실제 삭제 동작
modalStore.reset(); // 모달 닫기
}}
/>
);
Comment on lines +42 to +55
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

이 확인창에서 reset()을 쓰면 다른 전역 모달까지 같이 닫힙니다.

onCloseonConfirm이 모두 modalStore.reset()을 호출해서, 이 삭제 모달과 무관한 다른 모달도 함께 사라집니다. 여기서는 고정 ID를 넘기고 modalStore.close(id)로 자기 자신만 닫도록 분리하는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-detail/ui/experience-viewer/experience-viewer.tsx`
around lines 42 - 55, The delete modal currently calls modalStore.reset() in
handleOpenDeleteModal which closes all global modals; change it to open the
ModalBasic with a fixed unique id (e.g. const id = 'experience-delete') passed
to modalStore.open and then replace modalStore.reset() in both onClose and
onConfirm with modalStore.close(id) so only this modal is closed (keep onConfirm
calling onClickDelete() before modalStore.close(id)). Ensure you pass the id
when opening and use the same id in onClose/onConfirm.

};

return (
<main className={s.page}>
<StickyHeader
Expand All @@ -53,7 +66,7 @@ const ExperienceViewer = () => {
<Button
variant="secondary"
size="small"
onClick={toggleDeleteModal}
onClick={handleOpenDeleteModal}
>
삭제하기
</Button>
Expand Down Expand Up @@ -130,19 +143,6 @@ const ExperienceViewer = () => {
</div>
</div>
</section>

<ModalBasic
title="이 경험을 삭제하시겠습니까?"
subTitle="작성한 내용은 즉시 제거되며, 복구할 수 없습니다."
closeText="취소"
confirmText="삭제"
isOpen={isDeleteModalOpen}
onClose={toggleDeleteModal}
onConfirm={() => {
toggleDeleteModal();
onClickDelete();
}}
/>
</main>
);
};
Expand Down
Loading
Loading