Skip to content
Draft
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
7 changes: 6 additions & 1 deletion frontend/docs/mustpass/stack-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,9 @@

## 데스크톱

- [ ] PC 뷰포트에서도 화면·슬라이드 전환이 가운데 `max-w-md`(448px) 컨테이너 안에서만 일어난다.
- [ ] `lg` 미만에서는 가운데 `max-w-md`(448px) 캔버스와 하단 5슬롯 내비게이션이 유지된다.
- [ ] `lg` 이상에서는 앱 캔버스가 최대 1440px까지 확장되고, 홈·몽글라인·사람·설정과
기록 작성 슬롯이 같은 순서의 좌측 내비게이션으로 표시된다.
- [ ] PC의 홈은 소개/기간과 관계 지도가 2열로, 몽글라인은 활동 흐름/필터와 피드가 2열로
표시되며 가로 넘침 없이 각각 스크롤·조작할 수 있다.
- [ ] 상세·설정·기록 작성·사람 등록 화면은 넓은 캔버스에서도 읽기 좋은 중앙 폭을 유지한다.
2 changes: 1 addition & 1 deletion frontend/src/components/home/relation-force-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export function RelationForceMap({

return (
<div
className="relative mt-0 h-[480px] touch-pan-y overflow-hidden bg-background select-none"
className="relative mt-0 h-[480px] touch-pan-y overflow-hidden bg-background select-none lg:h-[min(72vh,660px)] lg:min-h-[520px] lg:rounded-[2rem] lg:border lg:border-border/70 lg:bg-card lg:shadow-[0_28px_70px_-42px_rgba(0,0,0,0.35)]"
onPointerDown={(event) => {
if (event.button !== 0) return
if (isPersonNodeTarget(event.target)) return
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/timeline/timeline-event-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function TimelineEventCard({ item }: { item: TimelineEventCardItem }) {
<button
type="button"
onClick={() => push('EventDetail', { eventId: String(item.id) })}
className="block min-w-0 flex-1 text-left"
className="block w-full min-w-0 flex-1 text-left"
>
<Card className="relative overflow-hidden py-0 shadow-[0_10px_30px_rgba(0,0,0,0.045)] transition-all hover:-translate-y-0.5 hover:bg-muted/20 hover:shadow-[0_14px_36px_rgba(0,0,0,0.07)]">
<CardContent className="p-4">
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/timeline/timeline-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ export function TimelineFeed<T extends TimelineFeedItem>({
if (items.length === 0) return null

return (
<div className={cn('relative min-w-0 pb-20', scrollRootRef && 'pr-0.5')}>
<div
className={cn(
'relative min-w-0 pb-20 lg:pb-8',
scrollRootRef && 'pr-0.5',
)}
>
{yearGroups.map((group) => (
<section key={group.year} className="relative">
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function TimelineFilterDrawer({

<DrawerContent
aria-describedby={undefined}
className="mx-auto w-full max-w-md overflow-hidden border-x border-border bg-background shadow-2xl data-[vaul-drawer-direction=bottom]:rounded-t-[5rem]"
className="mx-auto w-full max-w-md overflow-hidden border-x border-border bg-background shadow-2xl data-[vaul-drawer-direction=bottom]:rounded-t-[5rem] lg:max-w-xl lg:data-[vaul-drawer-direction=bottom]:rounded-t-[2rem]"
>
<DrawerHeader className="px-5 pt-5 text-left">
<DrawerTitle className="text-lg font-extrabold tracking-tight">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function AppBootstrap() {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<StackViewport>
<StackViewport desktop>
<Suspense fallback={null}>
<AppStack />
</Suspense>
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/stackflow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@
유지한다.
- **딥링크**: 상세 URL 직접 진입 시 `defaultHistory`가 아래에 Main(해당 탭)을 깔아
뒤로가기가 앱 이탈이 되지 않는다.
- **데스크톱**: `main.tsx`가 스택 전체를 `mx-auto max-w-md`(448px) relative 컨테이너에
가둔다. basic-ui 화면은 fixed가 아니라 positioned 조상 기준 absolute라 슬라이드도 그 안에서만 일어난다.
- **반응형 앱 캔버스**: 모바일·태블릿은 기존 `max-w-md`(448px) 캔버스와 하단 탭을
유지한다. 인증 완료 앱은 `lg`부터 최대 1440px 캔버스로 확장하고 좌측 내비게이션을 쓴다.
온보딩은 집중형 입력 흐름이라 448px 캔버스를 유지한다. basic-ui 화면은 fixed가 아니라
positioned 조상 기준 absolute라 슬라이드는 각 캔버스 안에서만 일어난다.
- history-sync가 **생성하는 URL에는 항상 trailing slash가 붙는다**(`/home/`, 라이브러리
하드코딩). 인바운드 매칭은 슬래시 유무 모두 허용하므로 동작엔 영향 없다.
- 셸 컴포넌트: 탭 화면은 `components/tab-shell.tsx`, push 화면은
`components/activity-shell.tsx` (구 AppShell의 대응물, dvh 대신 h-full 기준).
데스크톱 컨테이너는 `components/stack-viewport.tsx` (앱·온보딩 스택 공용).
반응형 컨테이너는 `components/stack-viewport.tsx` (앱·온보딩 스택 공용), 탭 위치 전환은
`components/stack-tab-bar.tsx`가 담당한다.

## 온보딩 퍼널 (`onboarding/`)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const EventDetailActivity: ActivityComponentType<'EventDetail'> = ({
const memo = event.memo?.trim() ?? ''

return (
<ActivityShell className="px-0">
<ActivityShell className="px-0 lg:px-0">
<header className="grid shrink-0 grid-cols-3 items-center px-5 py-1">
<button
type="button"
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/stackflow/activities/main-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ const TAB_COMPONENTS: Record<MainTab, React.ComponentType> = {
settings: SettingsTab,
}

// 하단 탭 4개를 품는 단일 activity. 탭 전환은 step(replaceStep)이라
// 히스토리가 쌓이지 않고, 이 컴포넌트가 언마운트되지 않아 탭 상태가 보존된다.
// 탭 4개와 반응형 내비게이션을 품는 단일 activity. 탭 전환은
// step(replaceStep)이라 히스토리가 쌓이지 않고 탭 상태가 보존된다.
export const MainActivity: ActivityComponentType<'Main'> = ({ params }) => {
const tab: MainTab = isMainTab(params.tab) ? params.tab : 'home'
// 방문한 탭만 마운트하고, 한 번 방문한 탭은 hidden으로 유지한다.
Expand All @@ -27,17 +27,21 @@ export const MainActivity: ActivityComponentType<'Main'> = ({ params }) => {

return (
<AppScreen>
<div className="relative flex h-full flex-col bg-background">
<div className="relative flex h-full flex-col bg-background lg:flex-row lg:bg-muted/35">
<StackTabBar activeTab={tab} />
{MAIN_TABS.map((t) => {
if (!visitedTabs.current.has(t)) return null
const Tab = TAB_COMPONENTS[t]
return (
<div key={t} hidden={t !== tab} className="relative min-h-0 flex-1">
<div
key={t}
hidden={t !== tab}
className="relative min-h-0 flex-1 bg-background"
>
<Tab />
</div>
)
})}
<StackTabBar activeTab={tab} />
</div>
</AppScreen>
)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/stackflow/activities/person-edit-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export const PersonEditActivity: ActivityComponentType<'PersonEdit'> = ({
})

return (
<ActivityShell layout="fixed" className="px-0">
<ActivityShell layout="fixed" className="px-0 lg:px-0">
<FormPageHeader
onBack={() => pop()}
title="프로필 수정"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/stackflow/activities/person-new-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ export const PersonNewActivity: ActivityComponentType<'PersonNew'> = () => {
function PersonNewScreen({ children }: { children: React.ReactNode }) {
return (
<AppScreen>
<div className="mx-auto flex h-full max-w-md flex-col bg-background">
<div className="mx-auto flex h-full max-w-md flex-col bg-background lg:my-6 lg:h-[calc(100%-3rem)] lg:max-w-xl lg:overflow-hidden lg:rounded-[2rem] lg:border lg:border-border/70 lg:shadow-2xl">
{children}
</div>
</AppScreen>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/stackflow/activities/record-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ function RecordScreen({
<AppScreen
CUPERTINO_ONLY_modalPresentationStyle={slideIn ? undefined : 'fullScreen'}
>
<div className="mx-auto flex h-full max-w-md flex-col overflow-y-auto bg-background">
<div className="mx-auto flex h-full max-w-md flex-col overflow-y-auto bg-background lg:my-6 lg:h-[calc(100%-3rem)] lg:max-w-xl lg:rounded-[2rem] lg:border lg:border-border/70 lg:shadow-2xl">
{children}
</div>
</AppScreen>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/stackflow/components/activity-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function ActivityShell({
>
<div
className={cn(
'relative bg-background px-5 pt-[max(1.5rem,env(safe-area-inset-top))]',
'relative bg-background px-5 pt-[max(1.5rem,env(safe-area-inset-top))] lg:mx-auto lg:w-full lg:max-w-3xl lg:border-x lg:border-border/70 lg:px-10 lg:pt-8',
layout === 'fixed'
? 'flex h-full min-h-0 min-w-0 flex-col overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]'
: 'h-full overflow-y-auto pb-8 [-webkit-overflow-scrolling:touch]',
Expand Down
58 changes: 53 additions & 5 deletions frontend/src/stackflow/components/stack-tab-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useFlow, useStepFlow } from '@stackflow/react'
import { Clock, Home, Plus, Settings, Users } from 'lucide-react'
import { MongleLogo } from '@/components/brand/mongle-logo'
import { cn } from '@/lib/utils'
import type { MainTab } from '@/stackflow/stackflow.config'

Expand All @@ -10,9 +11,8 @@ const tabs: Array<{ tab: MainTab; label: string; icon: typeof Home }> = [
{ tab: 'settings', label: '설정', icon: Settings },
]

// BottomNav(components/layout/bottom-nav.tsx)의 스택 버전.
// 탭 전환은 push가 아니라 replaceStep이라 히스토리가 쌓이지 않고,
// Main activity가 언마운트되지 않으므로 탭 상태가 보존된다.
// 모바일은 하단 탭, PC는 좌측 내비게이션으로 표시한다. 둘 다 같은
// replaceStep을 사용하므로 히스토리와 탭 상태 보존 계약은 바뀌지 않는다.
export function StackTabBar({ activeTab }: { activeTab: MainTab }) {
const { replaceStep } = useStepFlow('Main')
const { push } = useFlow()
Expand All @@ -24,8 +24,8 @@ export function StackTabBar({ activeTab }: { activeTab: MainTab }) {
]

return (
<nav className="absolute right-0 bottom-0 left-0 z-50 border-t border-border bg-background">
<div className="mx-auto flex h-[calc(4.75rem+env(safe-area-inset-bottom))] max-w-md items-end justify-around px-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))]">
<nav className="absolute right-0 bottom-0 left-0 z-50 border-t border-border bg-background lg:static lg:h-full lg:w-60 lg:shrink-0 lg:border-t-0 lg:border-r lg:bg-card/85 lg:backdrop-blur-xl">
<div className="mx-auto flex h-[calc(4.75rem+env(safe-area-inset-bottom))] max-w-md items-end justify-around px-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] lg:hidden">
{items.map((item) => {
if (item.kind === 'fab') {
return (
Expand All @@ -47,6 +47,7 @@ export function StackTabBar({ activeTab }: { activeTab: MainTab }) {
key={item.tab}
type="button"
onClick={() => replaceStep({ tab: item.tab })}
aria-current={activeTab === item.tab ? 'page' : undefined}
className={cn(
'flex flex-col items-center gap-1 text-[11px] font-bold',
activeTab === item.tab
Expand All @@ -60,6 +61,53 @@ export function StackTabBar({ activeTab }: { activeTab: MainTab }) {
)
})}
</div>

<div className="hidden h-full flex-col px-4 py-7 lg:flex">
<MongleLogo
className="px-3 text-foreground"
iconClassName="size-7"
textClassName="text-base"
/>

<div className="mt-10 space-y-1.5">
{items.map((item) => {
if (item.kind === 'fab') {
return (
<button
key="record"
type="button"
onClick={() => push('Record', {})}
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl border border-foreground bg-background text-sm font-extrabold text-foreground shadow-sm transition-colors hover:bg-foreground hover:text-background"
>
<Plus className="size-[1.125rem]" />
기록 작성
</button>
)
}

const Icon = item.icon
const isActive = activeTab === item.tab

return (
<button
key={item.tab}
type="button"
onClick={() => replaceStep({ tab: item.tab })}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex h-11 w-full items-center gap-3 rounded-xl px-3 text-sm font-extrabold transition-colors',
isActive
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
<Icon className="size-[1.125rem]" />
{item.label}
</button>
)
})}
</div>
</div>
</nav>
)
}
24 changes: 19 additions & 5 deletions frontend/src/stackflow/components/stack-viewport.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
// PC에서도 모바일 폭(max-w-md) 안에서만 화면이 그려지고 슬라이드되도록
// 스택 전체를 가운데 정렬된 컨테이너에 가둔다.
// (basic-ui 화면은 fixed가 아니라 가장 가까운 positioned 조상 기준 absolute)
export function StackViewport({ children }: { children: React.ReactNode }) {
import { cn } from '@/lib/utils'

// basic-ui 화면은 fixed가 아니라 가장 가까운 positioned 조상 기준 absolute다.
// 모바일은 기존 448px 앱 캔버스를 유지하고, PC에서는 넓은 앱 캔버스 안에서
// 좌측 내비게이션과 각 화면의 데스크톱 레이아웃을 함께 그린다.
export function StackViewport({
children,
desktop = false,
}: {
children: React.ReactNode
desktop?: boolean
}) {
return (
<div className="relative mx-auto h-dvh w-full max-w-md overflow-hidden bg-background sm:border-x sm:border-border">
<div
className={cn(
'relative mx-auto h-dvh w-full max-w-md overflow-hidden bg-background sm:border-x sm:border-border',
desktop &&
'lg:max-w-[1440px] lg:shadow-[0_0_80px_rgba(0,0,0,0.06)] dark:lg:shadow-[0_0_80px_rgba(0,0,0,0.24)]',
)}
>
{children}
<div
id="stack-overlay-root"
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/stackflow/components/tab-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cn } from '@/lib/utils'

// Main activity의 탭 슬롯 안에서 쓰는 공용 셸. 구 AppShell의 main 영역 대응물로,
// 하단은 StackTabBar(오버레이)를 피할 만큼 패딩을 준다.
// 모바일 하단은 StackTabBar 오버레이를 피하고 PC에서는 콘텐츠 여백으로 전환한다.
// 탭마다 이 셸이 각자 스크롤 컨테이너를 가지므로 탭 전환 시 스크롤 위치도 보존된다.
export function TabShell({
children,
Expand All @@ -16,7 +16,7 @@ export function TabShell({
return (
<div
className={cn(
'px-5 pt-[max(1.5rem,env(safe-area-inset-top))] pb-[calc(4.75rem+env(safe-area-inset-bottom))]',
'px-5 pt-[max(1.5rem,env(safe-area-inset-top))] pb-[calc(4.75rem+env(safe-area-inset-bottom))] lg:px-10 lg:pt-8 lg:pb-8 xl:px-12',
layout === 'fixed'
? 'flex h-full min-h-0 min-w-0 flex-col overflow-hidden'
: 'h-full overflow-y-auto [-webkit-overflow-scrolling:touch]',
Expand Down
58 changes: 32 additions & 26 deletions frontend/src/stackflow/tabs/home-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,36 +62,42 @@ export function HomeTab() {

return (
<TabShell>
<header className="mb-3">
<MongleLogo className="mb-5 text-foreground" />
<h1 className="text-[22px] font-black leading-tight tracking-tight text-foreground">
함께한 순간, <br /> 몽글몽글 쌓이는 중
</h1>
</header>
<div className="lg:grid lg:h-full lg:min-h-0 lg:grid-cols-[minmax(16rem,0.8fr)_minmax(22rem,1.2fr)] lg:items-center lg:gap-6 xl:grid-cols-[minmax(16rem,0.7fr)_minmax(30rem,1.3fr)] xl:gap-12">
<div className="lg:self-center">
<header className="mb-3 lg:mb-8">
<MongleLogo className="mb-5 text-foreground lg:hidden" />
<h1 className="text-[22px] font-black leading-tight tracking-tight text-foreground lg:text-[34px] lg:leading-[1.2] lg:tracking-[-0.035em]">
함께한 순간, <br /> 몽글몽글 쌓이는 중
</h1>
</header>

<section className="mb-4">
<HomePeriodToggle value={period} onChange={handlePeriodChange} />
</section>
<section className="mb-4 lg:mb-0 lg:max-w-sm">
<HomePeriodToggle value={period} onChange={handlePeriodChange} />
</section>
</div>

{mapQuery.isPending ? (
<p className="py-12 text-center text-sm text-muted-foreground">
관계 지도를 불러오는 중…
</p>
) : mapQuery.isError || !mapData ? (
<p className="py-12 text-center text-sm text-destructive">
관계 지도를 불러오지 못했어요.
</p>
) : (
<RelationForceMap
key={period}
me={mapData.me}
nodes={graphNodes}
edges={visibleEdges}
/>
)}
<div className="min-w-0 lg:self-center">
{mapQuery.isPending ? (
<p className="py-12 text-center text-sm text-muted-foreground">
관계 지도를 불러오는 중…
</p>
) : mapQuery.isError || !mapData ? (
<p className="py-12 text-center text-sm text-destructive">
관계 지도를 불러오지 못했어요.
</p>
) : (
<RelationForceMap
key={period}
me={mapData.me}
nodes={graphNodes}
edges={visibleEdges}
/>
)}
</div>
</div>

{throwback && !throwbackDismissed ? (
<div className="pointer-events-none absolute right-4 bottom-[6.25rem] left-4 z-40">
<div className="pointer-events-none absolute right-4 bottom-[6.25rem] left-4 z-40 lg:right-12 lg:bottom-8 lg:left-auto lg:w-[26rem]">
<div
className={cn(
'pointer-events-auto mx-auto w-full max-w-md',
Expand Down
Loading
Loading