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
45 changes: 30 additions & 15 deletions src/app/(route)/all-link/AllLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import InfiniteScroll from '@/components/basics/InfiniteScroll/InfiniteScroll';
import LinkCard from '@/components/basics/LinkCard/LinkCard';
import DeleteLinkModal from '@/components/basics/LinkCard/components/DeleteLinkModal';
import Spinner from '@/components/basics/Spinner/Spinner';
import DetailPanelShell from '@/components/wrappers/LinkCardDetailPanel/DetailPanelShell';
import LinkCardDetailPanel from '@/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel';
import { useGetInfiniteLinks } from '@/hooks/useGetInfiniteLinks';
import { useGetLink } from '@/hooks/useGetLink';
Expand Down Expand Up @@ -144,7 +145,7 @@ const LinkCardItem = memo(
);

export default function AllLink() {
const { selectedLinkId, selectLink } = useLinkStore();
const { selectedLinkId, selectLink, setDetailPanelOpen } = useLinkStore();
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [isSocketConnected, setIsSocketConnected] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<EntityId>>(new Set());
Expand Down Expand Up @@ -697,6 +698,17 @@ export default function AllLink() {
}
}, [selectedLinkId]);

// 모바일에서 상세 패널이 화면 전체를 덮으므로, 사이드네비 트리거를 숨기도록 전역에 알린다.
useEffect(() => {
setDetailPanelOpen(isPanelOpen);
return () => setDetailPanelOpen(false);
}, [isPanelOpen, setDetailPanelOpen]);

const handleClosePanel = useCallback(() => {
setIsPanelOpen(false);
selectLink(null);
}, [selectLink]);

const handleToggleSelect = useCallback((id: EntityId) => {
setSelectedIds(prev => {
const next = new Set(prev);
Expand Down Expand Up @@ -786,9 +798,10 @@ export default function AllLink() {
return (
<div className="h-full min-w-0">
<div className="flex h-full min-w-0 flex-col xl:flex-row">
<div className="min-w-0 flex-1 px-10 py-15" onWheel={handleLeftPaneWheel}>
<div className="min-w-0 flex-1 px-4 py-6 xl:px-10 xl:py-15" onWheel={handleLeftPaneWheel}>
<div className="mx-auto flex h-full w-full max-w-200 flex-col gap-5">
<header className="flex items-center justify-between">
{/* pl-12: 모바일에서 fixed 사이드네비 트리거(좌상단 20~60px)와 겹치지 않도록 비켜준다 */}
<header className="sticky top-0 z-30 flex items-center justify-between bg-white pl-12 md:pl-0">
<div className="flex items-center gap-1">
<h1 className="font-title-md">전체 링크</h1>
<p className="font-body-md text-gray600">({count ?? links.length})</p>
Expand All @@ -808,7 +821,7 @@ export default function AllLink() {
) : (
<InfiniteScroll
ref={listRef}
className="custom-scrollbar h-full overflow-y-auto overscroll-contain p-1"
className="custom-scrollbar h-full overflow-x-hidden overflow-y-auto overscroll-contain p-1"
items={links}
getKey={item => item.id}
renderItem={renderItem}
Expand All @@ -823,15 +836,20 @@ export default function AllLink() {

{isPanelOpen && (
<aside className="xl:h-full xl:w-130 xl:shrink-0">
{/*
* 로딩·에러·빈 상태도 본문과 같은 DetailPanelShell을 거친다.
* 그래야 모바일에서 패널이 즉시 뜨고 그 안에서 로딩이 돌며,
* 반응형 분기가 본문과 어긋날 수 없다.
*/}
{isSelectedLinkLoading ? (
<div className="border-gray200 flex h-full items-center justify-center rounded-2xl border bg-white p-6">
<Spinner />
</div>
<DetailPanelShell onClose={handleClosePanel} centered>
<Spinner size={36} />
</DetailPanelShell>
) : isSelectedLinkError ? (
<div className="border-gray200 text-gray600 flex h-full flex-col items-center justify-center gap-2 rounded-2xl border bg-white p-6">
<DetailPanelShell onClose={handleClosePanel} centered>
<p>상세 정보를 불러오지 못했습니다.</p>
<Button onClick={() => refetchSelectedLink()} label="다시 시도" />
</div>
</DetailPanelShell>
) : selectedLinkDetail ? (
<LinkCardDetailPanel
id={selectedLinkDetail.id}
Expand All @@ -844,15 +862,12 @@ export default function AllLink() {
summaryErrorMessage={
selectedStatusInfo?.errorMessage ?? selectedLinkDetail.summaryErrorMessage
}
onClose={() => {
setIsPanelOpen(false);
selectLink(null);
}}
onClose={handleClosePanel}
/>
) : (
<div className="border-gray200 text-gray600 h-full rounded-2xl border bg-white p-6">
<DetailPanelShell onClose={handleClosePanel} centered>
상세 정보를 볼 링크를 선택해 주세요.
</div>
</DetailPanelShell>
)}
</aside>
)}
Expand Down
8 changes: 8 additions & 0 deletions src/app/(route)/chat/[id]/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ReportModal from '@/components/wrappers/ReportModal/ReportModal';
import { useChatStream } from '@/hooks/server/Chats/useChatStream';
import useKeyboardInset from '@/hooks/util/useKeyboardInset';
import { trackEvent, trackQueryFeedback } from '@/lib/client/analytics';
import { useLinkStore } from '@/stores/linkStore';
import { useModalStore } from '@/stores/modalStore';
import { showToast } from '@/stores/toastStore';
import type { ChatHistoryMessage } from '@/types/api/chatApi';
Expand Down Expand Up @@ -101,6 +102,13 @@ export default function Chat() {
const [isAwaitingResponse, setIsAwaitingResponse] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [selectedLink, setSelectedLink] = useState<ChatSocketLink | null>(null);
const setDetailPanelOpen = useLinkStore(state => state.setDetailPanelOpen);

// 모바일에서 상세 패널이 화면 전체를 덮으므로, 사이드네비 트리거를 숨기도록 전역에 알린다.
useEffect(() => {
setDetailPanelOpen(selectedLink !== null);
return () => setDetailPanelOpen(false);
}, [selectedLink, setDetailPanelOpen]);
const [historyCursor, setHistoryCursor] = useState<EntityId | null>(null);
const [historyHasNext, setHistoryHasNext] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
Expand Down
6 changes: 3 additions & 3 deletions src/components/basics/CardList/CardList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

import React from 'react';

// LinkCard Width = 48 (192px)

interface CardListProps {
children: React.ReactNode;
}

export default function CardList({ children }: CardListProps) {
// 답변 말풍선 안에 들어가므로 모바일에서는 2열로 두면 카드가 찌그러진다.
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-4">{children}</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-4">
{children}
</div>
);
}
14 changes: 0 additions & 14 deletions src/components/basics/InfiniteScroll/InfiniteScroll.style.ts

This file was deleted.

116 changes: 77 additions & 39 deletions src/components/basics/InfiniteScroll/InfiniteScroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,53 @@

import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';

import Spinner from '../Spinner/Spinner';
import { styles } from './InfiniteScroll.style';

const { root: rootCls, statusRow, loader: loaderCls, end, error } = styles();
export type ColumnConfig = {
/** < 768px */
base?: number;
/** >= 768px (Tailwind md) */
md?: number;
/** >= 1024px (Tailwind lg) */
lg?: number;
};

const DEFAULT_COLUMNS: ColumnConfig = { base: 2, md: 3, lg: 4 };
const ROW_ASPECT_RATIO = 232 / 182; // LinkCard 실제 비율 (aspect-[182/232])
const GRID_PADDING_X = 8; // 그리드 좌우 px-1 = 4 + 4
const COLUMN_GAP = 16;

/**
* 뷰포트 기준 컬럼 수.
* 컨테이너 폭으로 나누면 좌우 여백만큼 항상 작게 잡혀 Tailwind 브레이크포인트와
* 어긋나므로(390px 화면에서 컨테이너는 약 270px) matchMedia로 뷰포트를 직접 본다.
*/
function useResponsiveColumns({ base = 2, md = 3, lg = 4 }: ColumnConfig) {
const [currentColumns, setCurrentColumns] = useState(base);

useLayoutEffect(() => {
const lgQuery = window.matchMedia('(min-width: 1024px)');
const mdQuery = window.matchMedia('(min-width: 768px)');

const resolve = () => {
const next = lgQuery.matches ? lg : mdQuery.matches ? md : base;
setCurrentColumns(prev => (prev === next ? prev : next));
};

resolve();
lgQuery.addEventListener('change', resolve);
mdQuery.addEventListener('change', resolve);

const DEFAULT_COLUMNS = { mobile: 2, desktop: 4 };
const ROW_ASPECT_RATIO = 1.3; // Estimated aspect ratio for a row of cards (58/47)
return () => {
lgQuery.removeEventListener('change', resolve);
mdQuery.removeEventListener('change', resolve);
};
}, [base, md, lg]);

return currentColumns;
}

export type InfiniteScrollProps<T> = Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> & {
items: T[];
Expand All @@ -24,10 +62,7 @@ export type InfiniteScrollProps<T> = Omit<React.HTMLAttributes<HTMLDivElement>,
endMessage?: React.ReactNode;
errorSlot?: (msg: string) => React.ReactNode;
rowGap?: number;
columns?: {
mobile?: number;
desktop?: number;
};
columns?: ColumnConfig;
};

function InfiniteScrollInner<T>(
Expand All @@ -50,7 +85,7 @@ function InfiniteScrollInner<T>(
ref: React.ForwardedRef<HTMLDivElement>
) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const [currentColumns, setCurrentColumns] = useState(columns.mobile || 2);
const currentColumns = useResponsiveColumns(columns);
const [containerWidth, setContainerWidth] = useState(0);
const isFetchingRef = useRef(false);
const controllerRef = useRef<AbortController | null>(null);
Expand Down Expand Up @@ -91,22 +126,19 @@ function InfiniteScrollInner<T>(
[ref]
);

// Update columns based on container width
// 행 높이 추정에 쓸 컨테이너 폭만 측정한다 (컬럼 수는 뷰포트 기준으로 따로 계산).
useEffect(() => {
const el = scrollContainerRef.current;
if (!el) return;

const observer = new ResizeObserver(entries => {
const width = entries[0].contentRect.width;
setContainerWidth(width);

const newCols = width >= 768 ? columns.desktop || 4 : columns.mobile || 2;
setCurrentColumns(prev => (prev === newCols ? prev : newCols));
setContainerWidth(prev => (prev === width ? prev : width));
});

observer.observe(el);
return () => observer.disconnect();
}, [columns.desktop, columns.mobile]);
}, []);

// Group items into rows for grid virtualization
const rows = useMemo(() => {
Expand All @@ -120,8 +152,8 @@ function InfiniteScrollInner<T>(
const estimateRowSize = useCallback(() => {
if (!containerWidth) return 300; // 초기 안전값

const effectiveWidth = containerWidth - 32;
const itemWidth = (effectiveWidth - (currentColumns - 1) * 16) / currentColumns;
const effectiveWidth = containerWidth - GRID_PADDING_X;
const itemWidth = (effectiveWidth - (currentColumns - 1) * COLUMN_GAP) / currentColumns;

return itemWidth * ROW_ASPECT_RATIO + rowGap;
}, [containerWidth, currentColumns, rowGap]);
Expand Down Expand Up @@ -160,7 +192,7 @@ function InfiniteScrollInner<T>(
<div
ref={setRefs}
className={clsx(
'scrollbar-hide relative h-full w-full overflow-y-auto contain-strict',
'relative h-full w-full overflow-x-hidden overflow-y-auto contain-strict',
className
)}
style={{ WebkitOverflowScrolling: 'touch' }}
Expand Down Expand Up @@ -193,9 +225,9 @@ function InfiniteScrollInner<T>(
style={{
display: 'grid',
gridTemplateColumns: `repeat(${currentColumns}, minmax(0, 1fr))`,
columnGap: '16px', // gap-x-4
paddingLeft: '1rem',
paddingRight: '1rem',
columnGap: `${COLUMN_GAP}px`,
paddingLeft: `${GRID_PADDING_X / 2}px`, // px-1
paddingRight: `${GRID_PADDING_X / 2}px`,
}}
>
{rows[virtualRow.index]?.map((item, colIndex) => {
Expand All @@ -212,23 +244,29 @@ function InfiniteScrollInner<T>(
))}
</div>

{/* Status Indicators at the bottom of the scroll content */}
<div className="flex min-h-20 w-full flex-col items-center justify-center p-6">
{isLoading && (loader || <Spinner />)}

{!hasMore && items.length > 0 && (
<div className="text-sm text-gray-400 italic">
{endMessage || '모든 콘텐츠를 불러왔습니다.'}
</div>
)}

{errorMessage &&
(errorSlot ? (
errorSlot(errorMessage)
) : (
<div className="text-sm font-medium text-red-500">{errorMessage}</div>
))}
</div>
{/*
* Status Indicators at the bottom of the scroll content.
* 표시할 내용이 있을 때만 렌더한다 — 무조건 렌더하면 min-h-20 + p-6 만큼(약 104px)
* 리스트 아래에 빈 블록이 항상 남는다.
*/}
{(isLoading || errorMessage || (!hasMore && items.length > 0)) && (
<div className="flex min-h-20 w-full flex-col items-center justify-center p-6">
{isLoading && (loader || <Spinner />)}

{!hasMore && items.length > 0 && (
<div className="text-sm text-gray-400 italic">
{endMessage || '모든 콘텐츠를 불러왔습니다.'}
</div>
)}

{errorMessage &&
(errorSlot ? (
errorSlot(errorMessage)
) : (
<div className="text-sm font-medium text-red-500">{errorMessage}</div>
))}
</div>
)}
</div>
);
}
Expand Down
6 changes: 3 additions & 3 deletions src/components/basics/LinkCard/LinkCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const LinkCard = React.forwardRef<HTMLDivElement, LinkCardProps>(function LinkCa
return (
<div
ref={ref}
className="border-gray200 hover:bg-gray50 active:bg-blue50 focus:border-blue500 group relative flex h-[232px] w-[182px] shrink-0 cursor-pointer flex-col overflow-hidden rounded-2xl border transition-colors"
className="border-gray200 hover:bg-gray50 active:bg-blue50 focus:border-blue500 group relative flex aspect-[182/232] w-full cursor-pointer flex-col overflow-hidden rounded-2xl border transition-colors"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
Expand Down Expand Up @@ -110,12 +110,12 @@ const LinkCard = React.forwardRef<HTMLDivElement, LinkCardProps>(function LinkCa
/>
)}

<div className="bg-gray900 relative aspect-94/47 w-full max-w-47 shrink-0">
<div className="bg-gray900 relative aspect-94/47 w-full shrink-0">
<Image
src={safeImageUrl}
alt={title}
fill
sizes="(max-width: 480px) 50vw, (max-width: 768px) 33vw, 25vw"
sizes="(max-width: 767px) 50vw, (max-width: 1023px) 33vw, 25vw"
className="border-gray200 border-b object-cover"
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/basics/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
modalOverlayStyle,
} from './Modal.style';

const FOCUSABLE_SELECTORS = [
export const FOCUSABLE_SELECTORS = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
Expand Down
Loading
Loading