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
35 changes: 25 additions & 10 deletions src/app/(route)/chat/[id]/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import CopyButton from '@/components/wrappers/CopyButton';
import LinkCardDetailPanel from '@/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel';
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 { useModalStore } from '@/stores/modalStore';
import { showToast } from '@/stores/toastStore';
Expand Down Expand Up @@ -106,6 +107,8 @@ export default function Chat() {
const [historyBootstrapped, setHistoryBootstrapped] = useState(false);
const [showResponseBottomGap, setShowResponseBottomGap] = useState(false);
const [responseBottomGapHeight, setResponseBottomGapHeight] = useState(0);
// iOS Safari에서 소프트 키보드가 가린 높이. Android는 dvh가 처리하므로 0이다.
const keyboardInset = useKeyboardInset();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const scrollRootRef = useRef<HTMLDivElement | null>(null);
const latestUserMessageRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -456,7 +459,8 @@ export default function Chat() {
const nextGapHeight = Math.max(root.clientHeight - latestUserMessage.offsetHeight, 0);
setResponseBottomGapHeight(prev => (Math.abs(prev - nextGapHeight) < 1 ? prev : nextGapHeight));
scrollLatestQuestionToTop('auto');
}, [messages, scrollLatestQuestionToTop, showResponseBottomGap]);
// keyboardInset이 바뀌면 스크롤 영역 높이도 바뀌므로 스페이서를 다시 계산한다.
}, [messages, scrollLatestQuestionToTop, showResponseBottomGap, keyboardInset]);

useEffect(() => {
const adjust = pendingScrollAdjustRef.current;
Expand Down Expand Up @@ -654,12 +658,21 @@ export default function Chat() {
}, [latestUserMessageIndex, messages]);

return (
<div className="h-full w-full xl:flex">
<div className="relative h-full min-w-0 flex-1">
<div
className="flex h-full w-full flex-col xl:flex-row"
style={{ paddingBottom: keyboardInset }}
>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
{/* 스크롤되는 메시지가 좌상단 고정 메뉴 버튼 뒤로 비치지 않도록 가리는 스크림 */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 z-20 h-15 bg-gradient-to-b from-white via-white/85 to-transparent xl:hidden"
/>

<div
ref={scrollRootRef}
onScroll={handleScroll}
className="custom-scrollbar h-full overflow-x-hidden overflow-y-auto overscroll-contain pr-1"
className="custom-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1"
>
<div className="mx-auto flex min-h-full w-full max-w-[816px] flex-col px-4 pt-15">
{streamError && (
Expand All @@ -668,7 +681,8 @@ export default function Chat() {
</div>
)}

<div className="flex min-h-0 flex-1 flex-col gap-3 pb-42">
{/* 입력창이 더 이상 오버레이가 아니므로 예약 여백(pb-42)이 필요 없다. */}
<div className="flex min-h-0 flex-1 flex-col gap-3 pb-4">
{historyLoading && historyBootstrapped && (
<div className="text-gray500 text-center text-xs">이전 대화를 불러오는 중...</div>
)}
Expand All @@ -690,8 +704,8 @@ export default function Chat() {
}`}
>
{message.role === 'user' ? (
<div className="max-w-[70%]">
<div className="bg-blue50 text-gray900 rounded-2xl px-4 py-3 whitespace-pre-wrap">
<div className="max-w-[85%] md:max-w-[70%]">
<div className="bg-blue50 text-gray900 rounded-2xl px-4 py-3 break-words whitespace-pre-wrap">
{message.text}
</div>
<div className="mt-2 flex justify-end">
Expand All @@ -710,7 +724,7 @@ export default function Chat() {
tabs={['답변', '링크']}
contents={{
답변: (
<div className="font-body-md text-gray700 whitespace-pre-wrap">
<div className="font-body-md text-gray700 break-words whitespace-pre-wrap">
<div>{message.text}</div>
{message.links && message.links.length > 0 && (
<div className="mt-4">
Expand Down Expand Up @@ -795,8 +809,9 @@ export default function Chat() {
</div>
</div>

<div className="absolute bottom-0 left-0 z-10 mb-15 flex w-full justify-center px-4">
<div className="w-full max-w-[816px] shrink-0">
{/* 입력창은 오버레이가 아니라 일반 flex 자식이라 dvh가 줄면 함께 따라 올라간다. */}
<div className="z-10 shrink-0 bg-white px-4 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)] xl:pb-15">
<div className="mx-auto w-full max-w-[816px]">
<ChatQueryBox onSubmit={handleSubmit} disabled={!connected || isAwaitingResponse} />
</div>
</div>
Expand Down
8 changes: 4 additions & 4 deletions src/app/(route)/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export default function Home() {
// 로딩중 화면
if (creating || redirecting) {
return (
<div className="relative flex h-full w-full justify-center">
<div className="relative flex h-full w-full max-w-[816px] flex-1 flex-col px-4">
<div className="relative flex h-full w-full flex-col items-center">
<div className="relative flex w-full max-w-[816px] flex-1 flex-col px-4">
<div className="absolute top-15 right-4 max-w-[70%]">
<span className="bg-blue50 block rounded-2xl px-4 py-3 whitespace-pre-wrap">
{pendingQuestion}
Expand All @@ -41,8 +41,8 @@ export default function Home() {
</div>
{error && <p className="absolute top-0 left-0 text-red-500">문제가 발생했습니다.</p>}
</div>
<div className="absolute bottom-0 left-0 z-10 mb-15 flex w-full justify-center px-4">
<div className="w-full max-w-[816px] shrink-0">
<div className="z-10 w-full shrink-0 bg-white px-4 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)] xl:pb-15">
<div className="mx-auto w-full max-w-[816px]">
<ChatQueryBox onSubmit={() => {}} disabled />
</div>
</div>
Expand Down
6 changes: 6 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export const metadata: Metadata = {
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
// 노치/홈 인디케이터 영역까지 그리고, env(safe-area-inset-*)를 활성화한다.
viewportFit: 'cover',
// Android Chrome: 소프트 키보드가 레이아웃 뷰포트를 축소해 dvh가 함께 줄어든다.
// (iOS Safari는 이 힌트를 무시하므로 useKeyboardInset으로 보정한다.)
interactiveWidget: 'resizes-content',
themeColor: '#ffffff',
};

export default function RootLayout({
Expand Down
5 changes: 4 additions & 1 deletion src/components/basics/CardList/CardList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ interface CardListProps {
}

export default function CardList({ children }: CardListProps) {
return <div className="grid grid-cols-2 gap-4 md:grid-cols-4">{children}</div>;
// 답변 말풍선 안에 들어가므로 모바일에서는 2열로 두면 카드가 찌그러진다.
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-4">{children}</div>
);
}
2 changes: 1 addition & 1 deletion src/components/basics/Modal/Modal.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const modalOverlayStyle = tv({

export const modalContentStyle = tv({
// 짧은 모바일 화면에서 긴 본문이 잘리지 않도록 최대 높이 + 자체 스크롤
base: 'bg-gray50 custom-scrollbar max-md:modal-slide-up relative h-auto max-h-[85dvh] w-auto overflow-y-auto overscroll-contain rounded-2xl shadow-lg max-md:m-0! max-md:w-full! max-md:max-w-none! max-md:min-w-0! max-md:rounded-b-none!',
base: 'bg-gray50 custom-scrollbar max-md:modal-slide-up relative h-auto max-h-[85dvh] w-auto overflow-y-auto overscroll-contain rounded-2xl shadow-lg max-md:m-0! max-md:w-full! max-md:max-w-none! max-md:min-w-0! max-md:rounded-b-none! max-md:pb-[env(safe-area-inset-bottom)]',
});

export const modalHeaderStyle = tv({
Expand Down
3 changes: 2 additions & 1 deletion src/components/basics/Tab/Tab.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export const tabDividerStyle = tv({
});

export const contentStyle = tv({
base: 'mt-8 px-6',
// 모바일에서는 페이지·말풍선 여백과 겹쳐 본문이 지나치게 좁아지므로 안쪽 여백을 없앤다.
base: 'mt-6 px-0 sm:mt-8 sm:px-6',
});
3 changes: 2 additions & 1 deletion src/components/basics/Toast/ToastContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const ToastContainer = () => {
return createPortal(
<div
className={clsx(
'pointer-events-none fixed inset-0 z-60 flex flex-col items-center justify-end gap-3 px-4 pb-30'
// pb-30(120px)은 하단 입력창을 피하기 위한 값. 모바일에서는 홈 인디케이터만큼 더 띄운다.
'pointer-events-none fixed inset-0 z-60 flex flex-col items-center justify-end gap-3 px-4 pb-[calc(7.5rem+env(safe-area-inset-bottom))]'
)}
>
<div className="flex flex-col items-center gap-3">
Expand Down
4 changes: 2 additions & 2 deletions src/components/layout/SideNavigation/SideNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export default function SideNavigation() {
exit={{ x: '-100%' }}
transition={{ type: 'spring', stiffness: 200, damping: 24 }}
// 스크림이 더 이상 어둡지 않으므로 분리감은 elevation 으로 확보한다
className="bg-gray50 pointer-events-auto absolute top-0 left-0 flex h-full w-60 flex-col overflow-hidden p-5 shadow-[0_0_24px_rgba(17,19,29,0.16)] outline-none"
className="bg-gray50 pointer-events-auto absolute top-0 left-0 flex h-full w-60 flex-col overflow-hidden p-5 pt-[max(1.25rem,env(safe-area-inset-top))] pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-[0_0_24px_rgba(17,19,29,0.16)] outline-none"
>
<div className={SCROLLER_CLASS}>
{/* 플로팅 트리거 자리 */}
Expand All @@ -105,7 +105,7 @@ export default function SideNavigation() {
</AnimatePresence>

{/* 트리거는 DOM 상 마지막 = 같은 z 안에서 맨 위. 마운트/언마운트 없이 항상 렌더 */}
<div className="pointer-events-auto absolute top-5 left-5">
<div className="pointer-events-auto absolute top-[max(1.25rem,env(safe-area-inset-top))] left-5">
<SideNavigationHeader
ref={triggerRef}
isOpen={isOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { tv } from 'tailwind-variants';

export const styles = tv({
slots: {
root: 'bg-gray50 custom-scrollbar fixed inset-0 z-50 w-full overflow-x-hidden overflow-y-auto overscroll-contain pt-2 pr-1 sm:inset-y-0 sm:right-0 sm:left-auto sm:w-[520px] xl:static xl:h-full xl:w-[520px]',
content: 'flex h-full flex-col gap-0 pb-6',
root: 'bg-gray50 custom-scrollbar fixed inset-0 z-50 w-full overflow-x-hidden overflow-y-auto overscroll-contain pt-[max(0.5rem,env(safe-area-inset-top))] pr-1 sm:inset-y-0 sm:right-0 sm:left-auto sm:w-[520px] xl:static xl:h-full xl:w-[520px] xl:pt-2',
Comment thread
Bangdayeon marked this conversation as resolved.
content: 'flex h-full flex-col gap-0 pb-[max(1.5rem,env(safe-area-inset-bottom))]',
header: 'flex items-center justify-between px-5 py-3',
section: 'flex flex-col gap-2 px-5 pt-5 pb-4',
titleCard:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { styles } from '@/components/wrappers/LinkCardDetailPanel/LinkCardDetail
import { getSafeUrl } from '@/hooks/util/getSafeUrl';
import { useModalStore } from '@/stores/modalStore';
import type { EntityId } from '@/types/id';
import { useMediaQuery, useScrollLock } from '@reactuses/core';
import { useCallback, useEffect } from 'react';

import ReSummaryModal from '../ReSummaryModal/ReSummaryModal';
import HeaderSection from './Sections/HeaderSection';
Expand Down Expand Up @@ -41,6 +43,20 @@ const LinkCardDetailPanel = ({
const { root, content } = styles();
const { modal } = useModalStore();

// xl 미만에서는 패널이 전체 화면 오버레이(fixed inset-0)라 뒤 배경이 같이 스크롤되면 안 된다.
const isOverlay = useMediaQuery('(max-width: 1279px)');

// useScrollLock의 내부 effect는 [locked, target]에 의존하며 실행될 때마다 "복원할 원래
// overflow"를 현재 값으로 다시 캡처한다. 매 렌더마다 새 함수를 넘기면 잠긴 상태의
// 'hidden'이 복원 값으로 덮어써져 패널을 닫아도 스크롤이 살아나지 않으므로 참조를 고정한다.
const getBody = useCallback(() => document.body, []);
const [, setScrollLocked] = useScrollLock(getBody);

useEffect(() => {
setScrollLocked(isOverlay);
return () => setScrollLocked(false);
}, [isOverlay, setScrollLocked]);

return (
<>
<aside className={root()}>
Expand Down
40 changes: 40 additions & 0 deletions src/hooks/util/useKeyboardInset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client';

import { useEffect, useState } from 'react';

/**
* 소프트 키보드가 화면 하단을 가린 높이(px)를 반환한다.
*
* Android Chrome은 viewport meta의 `interactive-widget=resizes-content` 덕분에
* 레이아웃 뷰포트(window.innerHeight)가 함께 줄어들어 0을 반환한다. 이 경우
* CSS의 dvh만으로 이미 처리되므로 추가 보정이 필요 없다.
*
* iOS Safari는 `interactive-widget`을 지원하지 않고 비주얼 뷰포트만 축소하므로
* innerHeight가 그대로 남아 실제 키보드 높이가 계산된다.
* 즉 플랫폼 분기 없이 필요한 곳에서만 값이 나온다.
*/
export default function useKeyboardInset() {
const [inset, setInset] = useState(0);

useEffect(() => {
const viewport = window.visualViewport;
if (!viewport) return;

const update = () => {
// offsetTop을 더해야 iOS가 페이지 자체를 밀어 올린 경우까지 보정된다.
const next = Math.max(0, window.innerHeight - (viewport.height + viewport.offsetTop));
setInset(prev => (Math.abs(prev - next) < 1 ? prev : next));
};

update();
viewport.addEventListener('resize', update);
viewport.addEventListener('scroll', update);

return () => {
viewport.removeEventListener('resize', update);
viewport.removeEventListener('scroll', update);
};
}, []);

return inset;
}
25 changes: 25 additions & 0 deletions src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,36 @@
}

@layer base {
html {
/* 가로 모드 전환 시 모바일 브라우저가 본문 글자를 임의로 키우는 것을 막는다. */
-webkit-text-size-adjust: 100%;
}

body {
@apply font-pretendard;
/* pull-to-refresh 및 내부 스크롤러 → 문서로의 scroll chaining 차단 */
overscroll-behavior-y: contain;
}

/* iOS Safari는 글자 크기가 16px 미만인 입력 요소에 포커스하면 화면을 자동 확대한다.
핀치 줌(접근성)은 유지한 채, 터치 기기에서 입력 요소의 최소 글자 크기만 보장한다. */
@media (pointer: coarse) {
input,
textarea,
select {
font-size: max(16px, 1rem);
}
}
}

/* ========== SAFE AREA ========== */
/* Tailwind v4는 config 파일 없이 CSS에서 유틸리티를 선언한다. */
@utility pt-safe {
padding-top: env(safe-area-inset-top);
}

@utility pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}

/* ========== Z-INDEX SCALE ==========
Expand Down
Loading