Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 90 additions & 10 deletions src/features/feed/ui/MapFeedCardPager.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client';

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -39,6 +41,7 @@ const STACK_BOTTOM_EXPANDED_DVH = -10;
const DECK_ANIMATION = MAP_FEED_CARD_DECK_ANIMATION;

type MapFeedCardPagerProps = {
hidden?: boolean;
snap: FeedCardPagerSnap;
onSnapChange: (snap: FeedCardPagerSnap) => void;
promotedCount: number;
Expand All @@ -54,6 +57,7 @@ type MapFeedCardPagerProps = {
type ExitDirection = CardDeckExitDirection;

export function MapFeedCardPager({
hidden = false,
snap,
onSnapChange,
promotedCount,
Expand All @@ -72,6 +76,9 @@ export function MapFeedCardPager({
const searchQuery = useFilterStore((s) => s.searchQuery);
const setPromotedCount = onPromotedCountChange;
const isStackDragging = useRef(false);
const previousSafePromoted = useRef(0);
const resetToPeekTimerRef = useRef<number | null>(null);
const [isResettingToPeek, setIsResettingToPeek] = useState(false);
// 좌/우 swipe 가 시작된 단일 카드만 override — 그 외는 기본 'down' exit.
// ref + forceRerender 안티패턴 대신 단일 state 로 정리 (한 시점에 override 대상은 1장).
const [exitOverride, setExitOverride] = useState<{
Expand Down Expand Up @@ -112,14 +119,72 @@ export function MapFeedCardPager({
const promotedItems = filtered.slice(visibleStart, visibleEnd);
const stackItems = filtered.slice(safePromoted, safePromoted + 4);

const clearResetToPeekTimer = useCallback(() => {
if (resetToPeekTimerRef.current === null) return;
window.clearTimeout(resetToPeekTimerRef.current);
resetToPeekTimerRef.current = null;
}, []);

const finishResetToPeek = useCallback(() => {
clearResetToPeekTimer();
setIsResettingToPeek(false);
onSnapChange('peek');
}, [clearResetToPeekTimer, onSnapChange]);

const holdStackUntilResetExitCompletes = useCallback(
(cardCount: number) => {
clearResetToPeekTimer();
if (cardCount <= 0) {
setIsResettingToPeek(false);
return;
}

setIsResettingToPeek(true);
// AnimatePresence onExitComplete is the source of truth, but keep a
// conservative fallback so the lower deck cannot stay locked if an
// exit is interrupted by route/HMR/state timing.
resetToPeekTimerRef.current = window.setTimeout(
finishResetToPeek,
cardCount * DECK_ANIMATION.staggerDelay * 1000 + 450,
);
},
[clearResetToPeekTimer, finishResetToPeek],
);

useEffect(() => {
const previous = previousSafePromoted.current;
previousSafePromoted.current = safePromoted;

if (previous > 0 && safePromoted === 0) {
holdStackUntilResetExitCompletes(previous);
return;
}

if (safePromoted > 0) {
clearResetToPeekTimer();
setIsResettingToPeek(false);
}
}, [safePromoted, clearResetToPeekTimer, holdStackUntilResetExitCompletes]);

useEffect(
() => () => {
clearResetToPeekTimer();
},
[clearResetToPeekTimer],
);

const isStackInteractionLocked = isResettingToPeek;

const isExpanded = snap === 'expanded';
const stackWidth = isExpanded ? STACK_WIDTH_EXPANDED : STACK_WIDTH_PEEK;
const stackBottom = isExpanded
? STACK_BOTTOM_EXPANDED_DVH
: STACK_BOTTOM_PEEK_DVH;

function promoteOne() {
if (safePromoted >= total) return;
if (safePromoted >= total) {
return;
}

const overflowItem =
promotedItems.length >= MAX_PROMOTED_FEED_CARDS
Expand Down Expand Up @@ -178,14 +243,13 @@ export function MapFeedCardPager({
return;
}
setPromotedCount(0);
// 가장 깊은 카드의 exit 가 끝난 시점 직후에 snap 전환.
setTimeout(
() => onSnapChange('peek'),
start * DECK_ANIMATION.staggerDelay * 1000,
);
holdStackUntilResetExitCompletes(start);
}

function handleStackDragEnd(_e: unknown, info: PanInfo) {
if (isStackInteractionLocked) {
return;
}
const dy = info.offset.y;
setTimeout(() => {
isStackDragging.current = false;
Expand All @@ -201,7 +265,12 @@ export function MapFeedCardPager({
}

function handleStackClick() {
if (isStackDragging.current) return;
if (isStackInteractionLocked) {
return;
}
if (isStackDragging.current) {
return;
}
if (snap === 'peek') onSnapChange('expanded');
else promoteOne();
}
Expand Down Expand Up @@ -234,7 +303,9 @@ export function MapFeedCardPager({
<>
{/* promote 된 카드 더미 — 화면 위쪽으로 쌓임 */}
<div
className="pointer-events-none fixed inset-x-0 z-30"
className={`pointer-events-none fixed inset-x-0 z-30 ${
hidden ? 'pointer-events-none opacity-0' : ''
}`}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
style={{ top: `${DECK_ANIMATION.cardTopDvh}dvh` }}
>
<div
Expand All @@ -246,6 +317,9 @@ export function MapFeedCardPager({
onExitComplete={() => {
setExitOverride(null);
setInstantDismissIds(new Set());
if (isResettingToPeek) {
finishResetToPeek();
}
}}
>
{promotedItems.map((item, i) => {
Expand Down Expand Up @@ -309,7 +383,9 @@ export function MapFeedCardPager({
<motion.div
key={item.id}
className={`absolute left-0 right-0 ${
isTop ? 'pointer-events-auto' : ''
isTop && !hidden
? 'pointer-events-auto'
: ''
}`}
style={{
zIndex: 100 - order,
Expand Down Expand Up @@ -404,7 +480,11 @@ export function MapFeedCardPager({

{/* 하단 뭉치 — 카드 자체가 drag 영역 */}
<motion.div
className="pointer-events-auto fixed left-1/2 z-30 -translate-x-1/2 cursor-grab touch-none active:cursor-grabbing"
className={`pointer-events-auto fixed left-1/2 z-30 -translate-x-1/2 cursor-grab touch-none active:cursor-grabbing ${
isStackInteractionLocked || hidden
? 'pointer-events-none opacity-0'
: ''
}`}
initial={false}
animate={{
width: stackWidth,
Expand Down
52 changes: 31 additions & 21 deletions src/features/map/client/MapClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export function MapClient() {
const [viewportBbox, setViewportBbox] = useState<ViewportBbox | null>(null);
const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
const isMapMarkerDeckOpen = selectedClusterId !== null;

const isFeedPagerHidden = isMapMarkerDeckOpen || isPagerHiddenByMarkerDeck;
// next-themes 의 resolvedTheme 은 초기 렌더에서 undefined — 이 상태로 MapV3Canvas 가 mount 되면
// customStyleId 가 잘못 지정되어 기본 스타일로 뜬 뒤 live swap 되며 깜빡인다.
// <html class="dark"> 는 next-themes inline 스크립트가 React 수화 전에 동기적으로 세팅하므로,
Expand Down Expand Up @@ -199,7 +199,9 @@ export function MapClient() {
const revealTimer = window.setTimeout(() => {
setIsPagerHiddenByMarkerDeck(false);
}, 120);
return () => window.clearTimeout(revealTimer);
return () => {
window.clearTimeout(revealTimer);
};
}, [isMapMarkerDeckOpen]);

const openMapTutorial = useCallback(() => {
Expand Down Expand Up @@ -536,20 +538,29 @@ export function MapClient() {

const handleClusterSelect = useCallback(
(clusterId: string) => {
setIsPagerHiddenByMarkerDeck(true);
setPagerSnap('peek');
setPagerPromotedCount(0);
updateUrl({ cluster: clusterId });
},
[updateUrl],
);

const handleFeedMarkerSelect = useCallback(
(feedId: string) => {
setIsPagerHiddenByMarkerDeck(true);
setPagerSnap('peek');
setPagerPromotedCount(0);
updateUrl({ cluster: `feed-${feedId}` });
},
[updateUrl],
);

const handleFeedMarkerGroupSelect = useCallback(
(groupId: string) => {
setIsPagerHiddenByMarkerDeck(true);
setPagerSnap('peek');
setPagerPromotedCount(0);
updateUrl({ cluster: groupId });
},
[updateUrl],
Expand Down Expand Up @@ -820,25 +831,24 @@ export function MapClient() {
onClose={() => updateUrl({ chat: false })}
/>

{!isPagerHiddenByMarkerDeck && (
<MapFeedCardPager
snap={pagerSnap}
onSnapChange={setPagerSnap}
promotedCount={pagerPromotedCount}
onPromotedCountChange={setPagerPromotedCount}
items={visibleFeedItems}
isInitialLoading={isInitialFeedLoading}
onTutorialCardPromote={showCardTutorialStep}
onTutorialCardDismiss={completeDeckTutorial}
onTutorialCardDetail={
tutorialOpen ? completeDeckTutorial : undefined
}
onBookmark={(item) => {
// TODO: 다음 PR에서 useAddFavorite mutation 연결
console.info('[bookmark]', item.id, item.title);
}}
/>
)}
<MapFeedCardPager
hidden={isFeedPagerHidden}
snap={pagerSnap}
onSnapChange={setPagerSnap}
promotedCount={pagerPromotedCount}
onPromotedCountChange={setPagerPromotedCount}
items={visibleFeedItems}
isInitialLoading={isInitialFeedLoading}
onTutorialCardPromote={showCardTutorialStep}
onTutorialCardDismiss={completeDeckTutorial}
onTutorialCardDetail={
tutorialOpen ? completeDeckTutorial : undefined
}
onBookmark={(item) => {
// TODO: 다음 PR에서 useAddFavorite mutation 연결
console.info('[bookmark]', item.id, item.title);
}}
/>

<FeedBottomSheet
open={feedListOpen}
Expand Down
2 changes: 1 addition & 1 deletion src/features/map/ui/MapCardDeckOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function MapCardDeckOverlay({
);
const topItem = visibleItems[0];
const hasNextItem = dismissedCount < items.length - 1;

function closeDeck() {
if (isClosingDeck) return;
setExitOverride(null);
Expand Down Expand Up @@ -151,7 +152,6 @@ export function MapCardDeckOverlay({
onExitComplete={() => {
setExitOverride(null);
if (!isClosingDeck) return;
setIsClosingDeck(false);
onCloseAction();
}}
>
Expand Down
Loading