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
3 changes: 1 addition & 2 deletions apps/web/src/app/archive/wish/[id]/_hooks/useDeleteWish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner';

import { deleteWish } from '@/apis/deleteWish';
import { ROUTES } from '@/consts/route';
import type { ApiErrorResponseT } from '@/types/api';

/** 위시 단건 삭제 */
Expand All @@ -17,7 +16,7 @@ export const useDeleteWish = (wishId: number) => {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wishlists'] });
toast.success('위시 상품이 삭제되었습니다.');
router.replace(ROUTES.WISHLIST);
router.back();
Comment thread
iOdiO89 marked this conversation as resolved.
},
onError: error => {
if (!isAxiosError<ApiErrorResponseT>(error) || !error.response) return;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/archive/wish/_components/WishlistList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useGetWishlist } from '@/hooks/useGetWishlist';
import { useSSEFallback } from '@/hooks/useSSEFallback';
import { hasParsingItems } from '@/utils/item';

import { useScrollRestoration } from '../_hooks/useScrollRestoration';
import WishCardSkeleton from './WishCardSkeleton';
import WishGridContent from './WishGridContent';

Expand All @@ -22,6 +23,7 @@ function WishlistList({ isDeleteMode, selectedIds, onToggleSelect }: WishlistLis
const hasPendingItem = hasParsingItems(wishlistData);

useSSEFallback(['wishlists'], hasPendingItem);
useScrollRestoration();

useEffect(() => {
const el = sentinelRef.current;
Expand Down
41 changes: 36 additions & 5 deletions apps/web/src/app/archive/wish/_components/wish-grid/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import Link from 'next/link';
import type { MouseEvent } from 'react';

import { CheckboxEmptyIconFill, CheckboxSelectedIconFill } from '@/assets/icons';
import WishCard from '@/components/common/wish-card';
import { ROUTES } from '@/consts/route';
import { Z_INDEX } from '@/consts/zIndex';
import type { WishItemT } from '@/types/wish';

import { saveWishScroll } from '../../_utils/wishScroll';
import WishFailedCard from './WishFailedCard';
import WishProcessingCard from './WishProcessingCard';

Expand All @@ -16,12 +19,24 @@ type WishGridProps = {
};

function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }: WishGridProps) {
const handleCardClick = (event: MouseEvent<HTMLAnchorElement>, wishId: number) => {
/** 새 탭/새 창 열기는 현재 페이지를 떠나지 않으므로 스크롤 위치를 저장하지 않는다. */
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;

saveWishScroll(event.currentTarget, wishId);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<div className="grid grid-cols-2">
{items.map((item, index) => {
if (item.status === 'FAILED')
return (
<Link href={ROUTES.WISH_EDIT(item.id)} key={item.id}>
<Link
href={ROUTES.WISH_EDIT(item.id)}
key={item.id}
data-wish-id={item.id}
onClick={event => handleCardClick(event, item.id)}
>
<WishFailedCard key={item.id} />
</Link>
);
Expand All @@ -38,9 +53,20 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }:
aria-pressed={isSelected}
className="relative cursor-pointer text-left transition-opacity active:opacity-80"
>
<WishCard name={item.name} price={item.price} imageUrl={item.imageUrl} sourcePlatform={item.sourcePlatform} />
<div className={`pointer-events-none absolute top-0 left-0 right-0 z-[11] aspect-[201/166] bg-black/20 transition-opacity duration-200 ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
<span className="pointer-events-none absolute top-3 left-3 z-[12] block size-5">
<WishCard
name={item.name}
price={item.price}
imageUrl={item.imageUrl}
sourcePlatform={item.sourcePlatform}
/>
<div
style={{ zIndex: Z_INDEX.BASE_IMAGE + 1 }}
className={`pointer-events-none absolute top-0 right-0 left-0 aspect-[201/166] bg-black/20 transition-opacity duration-200 ${isSelected ? 'opacity-100' : 'opacity-0'}`}
/>
<span
style={{ zIndex: Z_INDEX.BASE_IMAGE + 2 }}
className="pointer-events-none absolute top-3 left-3 block size-5"
>
{isSelected ? (
<CheckboxSelectedIconFill className="size-5 text-bg-accent" />
) : (
Expand All @@ -52,7 +78,12 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }:
}

return (
<Link href={ROUTES.WISH_EDIT(item.id)} key={item.id}>
<Link
href={ROUTES.WISH_EDIT(item.id)}
key={item.id}
data-wish-id={item.id}
onClick={event => handleCardClick(event, item.id)}
>
<WishCard
name={item.name}
price={item.price}
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/app/archive/wish/_hooks/useScrollRestoration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useLayoutEffect } from 'react';

import {
WISH_CARD_ID_ATTR,
clearWishScroll,
getCardOffset,
getScrollContainer,
readWishScroll,
} from '../_utils/wishScroll';

const RESTORE_DEADLINE_MS = 1000;
const TOLERANCE_PX = 1;

export const useScrollRestoration = () => {
useLayoutEffect(() => {
const container = getScrollContainer();
if (!container) return;

const anchor = readWishScroll();
if (!anchor) return;

let frame = 0;
const deadline = performance.now() + RESTORE_DEADLINE_MS;

/** 카드 렌더링이 끝나기 전에는 한 번에 복원되지 않을 수 있어 deadline까지 스크롤 복원 재시도 */
const restore = () => {
const card = container.querySelector<HTMLElement>(
`[${WISH_CARD_ID_ATTR}="${anchor.wishId}"]`
);

if (card) {
const delta = getCardOffset(container, card) - anchor.offset;
if (Math.abs(delta) < TOLERANCE_PX) {
clearWishScroll();
return;
}

container.scrollTop += delta;
}

if (performance.now() < deadline) frame = requestAnimationFrame(restore);
};

restore();

return () => cancelAnimationFrame(frame);
}, []);
};
64 changes: 64 additions & 0 deletions apps/web/src/app/archive/wish/_utils/wishScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { SCROLL_CONTAINER_ID } from '@/consts/layout';

const STORAGE_KEY_PREFIX = 'piki:wishScroll:';

const HISTORY_STATE_KEY = '__pikiScrollKey';

/** 스크롤 복원 기준 카드를 식별하기 위한 속성 */
export const WISH_CARD_ID_ATTR = 'data-wish-id';

export type WishScrollAnchorT = {
wishId: number;
offset: number;
};

const getStorageKey = () => {
const state: Record<string, unknown> = window.history.state ?? {};
const key = state[HISTORY_STATE_KEY];
if (typeof key === 'string') return `${STORAGE_KEY_PREFIX}${key}`;

const newKey = Math.random().toString(36).slice(2, 8);
window.history.replaceState({ ...state, [HISTORY_STATE_KEY]: newKey }, '');

return `${STORAGE_KEY_PREFIX}${newKey}`;
};

export const getScrollContainer = () => document.getElementById(SCROLL_CONTAINER_ID);

export const getCardOffset = (container: HTMLElement, card: HTMLElement) =>
card.getBoundingClientRect().top - container.getBoundingClientRect().top;

export const saveWishScroll = (card: HTMLElement, wishId: number) => {
const container = getScrollContainer();
if (!container) return;

const anchor: WishScrollAnchorT = { wishId, offset: getCardOffset(container, card) };

try {
sessionStorage.setItem(getStorageKey(), JSON.stringify(anchor));
} catch {
/** 스크롤 복원 실패는 치명적이지 않으므로 무시 */
}
};

export const readWishScroll = (): WishScrollAnchorT | null => {
try {
const raw = sessionStorage.getItem(getStorageKey());
if (!raw) return null;

const { wishId, offset }: Record<string, unknown> = JSON.parse(raw);
if (typeof wishId !== 'number' || typeof offset !== 'number') return null;

return { wishId, offset };
} catch {
return null;
}
};

export const clearWishScroll = () => {
try {
sessionStorage.removeItem(getStorageKey());
} catch {
/** 값이 남아도 무해하므로 무시 */
}
};
6 changes: 5 additions & 1 deletion apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { headers } from 'next/headers';
import React from 'react';

import BottomTabBar from '@/components/bottom-tab-bar';
import { SCROLL_CONTAINER_ID } from '@/consts/layout';
import { isWebview as _isWebView } from '@/utils/webBridge';

import Providers from '../components/Providers';
Expand Down Expand Up @@ -66,7 +67,10 @@ async function RootLayout({
<body className="h-full overflow-hidden">
<Providers>
{/** TEMP: max width 임시 값 */}
<div className="mx-auto hide-scrollbar h-full max-w-120 overflow-y-auto [scrollbar-gutter:stable]">
<div
id={SCROLL_CONTAINER_ID}
className="mx-auto hide-scrollbar h-full max-w-120 overflow-y-auto [scrollbar-gutter:stable]"
>
{children}
</div>

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/consts/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** window가 아닌 루트 스크롤 컨테이너를 조회하기 위한 ID */
export const SCROLL_CONTAINER_ID = 'scroll-container';
Loading