feat(app-hub): 스테이지 카드 칩 제거와 추천 공간 캐러셀 적용

맥락:
- 허브 스테이지 내부에 메모/전체공간 칩이 동시에 노출되어 시선이 분산되고 조작 밀도가 높았습니다.
- 추천 공간은 단순 스크롤보다 회전 가능한 캐러셀 인터랙션이 필요했습니다.

변경사항:
- 오늘의 공간 카드 헤더에서 메모 칩과 전체공간 칩을 제거했습니다.
- 추천 공간 섹션에 이전/다음 컨트롤을 추가해 캐러셀 회전이 가능하도록 변경했습니다.
- 전체 공간 진입은 칩 대신 텍스트 링크로 정리했습니다.
- 메모 진입 컴포넌트는 스테이지 카드 밖(하단)으로 이동해 카드 내부 밀도를 낮췄습니다.
- 스테이지 오버레이 구성을 유지하기 위해 관련 위젯 조합을 함께 정리했습니다.

검증:
- npx tsc --noEmit
- npm run build

세션-상태: 스테이지 카드 내부 칩 제거 및 추천 공간 캐러셀 반영 완료
세션-다음: 캐러셀 전환 모션(페이드/슬라이드) 미세 조정
세션-리스크: 추천 공간 수가 1개 이하일 때 회전 UI 효용이 낮아질 수 있음
This commit is contained in:
2026-03-02 11:12:08 +09:00
parent 47e80e59d2
commit 6dfa4677d9
5 changed files with 220 additions and 110 deletions

View File

@@ -29,8 +29,8 @@ export const RoomPreviewCard = ({
className={cn(
'group relative overflow-hidden rounded-3xl border text-left transition-all duration-250 motion-reduce:transition-none',
compact
? 'h-[112px] min-w-[164px] snap-start p-2.5 sm:min-w-0 sm:aspect-[4/3]'
: 'h-[338px] p-3 sm:h-[420px] sm:p-4',
? 'h-[98px] min-w-[152px] snap-start p-2 sm:min-w-0 sm:aspect-[5/4]'
: 'h-[292px] p-3 sm:h-[334px] sm:p-4 lg:h-[356px]',
selected
? cinematic
? 'border-sky-200/44 shadow-[0_0_0_1px_rgba(186,230,253,0.34),0_20px_50px_rgba(2,6,23,0.32)]'
@@ -86,14 +86,14 @@ export const RoomPreviewCard = ({
) : null}
{compact ? (
<div className="w-full rounded-lg bg-slate-950/44 px-2.5 py-2">
<div className="w-full rounded-lg bg-slate-950/44 px-2 py-1.5">
<p className="truncate text-sm font-semibold text-white">{room.name}</p>
<p className="mt-0.5 truncate text-[11px] text-white/70">{room.vibeLabel}</p>
</div>
) : (
<div className="w-full max-w-[440px] rounded-2xl border border-white/14 bg-slate-950/46 px-4 py-3 backdrop-blur-md">
<div className="w-full max-w-[420px] rounded-2xl border border-white/14 bg-slate-950/46 px-4 py-3 backdrop-blur-md">
<p className="text-[11px] uppercase tracking-[0.16em] text-white/56">Selected Space</p>
<h3 className="mt-1 text-2xl font-semibold tracking-tight text-white sm:text-[2rem]">
<h3 className="mt-1 text-2xl font-semibold tracking-tight text-white sm:text-[1.85rem]">
{room.name}
</h3>
<p className="mt-1 text-sm text-white/74">{room.description}</p>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { getRoomCardBackgroundStyle, ROOM_THEMES } from '@/entities/room';
import { ROOM_THEMES } from '@/entities/room';
import {
GOAL_CHIPS,
SOUND_PRESETS,
@@ -52,7 +52,7 @@ export const AppHubWidget = () => {
const router = useRouter();
const { pushToast } = useToast();
const { thoughts, thoughtCount, clearThoughts } = useThoughtInbox();
const { selectedRoom, selectedRoomId, selectRoom } = useRoomSelection(
const { selectedRoomId, selectRoom } = useRoomSelection(
ROOM_THEMES[0].id,
);
@@ -108,15 +108,11 @@ export const AppHubWidget = () => {
<div className="relative min-h-screen overflow-hidden text-white">
<div
aria-hidden
className={cn(
'absolute inset-0',
cinematic && 'scale-[1.01]',
)}
className={cn('absolute inset-0')}
style={{
...getRoomCardBackgroundStyle(selectedRoom),
filter: cinematic
? 'brightness(0.86) saturate(1.06) contrast(1.03)'
: 'brightness(0.94) saturate(0.96)',
backgroundImage: cinematic
? 'radial-gradient(132% 94% at 8% 0%, rgba(148,163,184,0.34) 0%, rgba(15,23,42,0) 46%), radial-gradient(116% 88% at 94% 6%, rgba(125,211,252,0.18) 0%, rgba(15,23,42,0) 52%), linear-gradient(162deg, #0f172a 0%, #111827 42%, #0b1220 100%)'
: 'radial-gradient(130% 92% at 8% 0%, rgba(148,163,184,0.22) 0%, rgba(248,250,252,0) 48%), radial-gradient(104% 86% at 90% 8%, rgba(125,211,252,0.2) 0%, rgba(248,250,252,0) 54%), linear-gradient(164deg, #f8fafc 0%, #e2e8f0 48%, #dbe7f5 100%)',
}}
/>
<div
@@ -124,8 +120,8 @@ export const AppHubWidget = () => {
className={cn(
'absolute inset-0',
cinematic
? 'bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.34)_50%,rgba(2,6,23,0.52)_100%)]'
: 'bg-[linear-gradient(180deg,rgba(248,250,252,0.44)_0%,rgba(241,245,249,0.22)_42%,rgba(15,23,42,0.3)_100%)]',
? 'bg-[radial-gradient(circle_at_24%_16%,rgba(148,163,184,0.14),transparent_36%),radial-gradient(circle_at_78%_14%,rgba(125,211,252,0.1),transparent_34%),linear-gradient(180deg,rgba(2,6,23,0.16)_0%,rgba(2,6,23,0.32)_52%,rgba(2,6,23,0.5)_100%)]'
: 'bg-[radial-gradient(circle_at_20%_14%,rgba(148,163,184,0.16),transparent_38%),radial-gradient(circle_at_82%_16%,rgba(125,211,252,0.12),transparent_38%),linear-gradient(180deg,rgba(248,250,252,0.32)_0%,rgba(241,245,249,0.12)_40%,rgba(15,23,42,0.26)_100%)]',
)}
/>
<div
@@ -157,8 +153,12 @@ export const AppHubWidget = () => {
/>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 pb-8 pt-5 sm:px-6 sm:pt-6 lg:px-8 lg:pb-10">
<div className="grid gap-4 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)] lg:gap-5">
<section className="order-1">
<RoomsGalleryWidget
visualMode={visualMode}
rooms={ROOM_THEMES}
selectedRoomId={selectedRoomId}
onRoomSelect={selectRoom}
startPanel={(
<StartRitualWidget
visualMode={visualMode}
goalInput={goalInput}
@@ -168,25 +168,16 @@ export const AppHubWidget = () => {
onGoalChipSelect={handleGoalChipSelect}
onQuickEnter={handleQuickEnter}
onOpenCustomEntry={() => setCustomEntryOpen(true)}
inStage
/>
</section>
<section className="order-2 lg:row-span-2">
<RoomsGalleryWidget
visualMode={visualMode}
rooms={ROOM_THEMES}
selectedRoomId={selectedRoomId}
onRoomSelect={selectRoom}
/>
</section>
<section className="order-3">
<ThoughtSummaryEntryWidget
visualMode={visualMode}
thoughtCount={thoughtCount}
onOpen={() => setThoughtInboxOpen(true)}
/>
</section>
)}
/>
<div className="mt-3 sm:mt-4">
<ThoughtSummaryEntryWidget
visualMode={visualMode}
thoughtCount={thoughtCount}
onOpen={() => setThoughtInboxOpen(true)}
/>
</div>
</main>
</div>

View File

@@ -1,5 +1,9 @@
import { useState } from 'react';
import { getHubRoomSections, type RoomTheme } from '@/entities/room';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
getHubRoomSections,
getRoomCardBackgroundStyle,
type RoomTheme,
} from '@/entities/room';
import { RoomPreviewCard } from '@/features/room-select';
import type { AppHubVisualMode } from '@/shared/config/appHubVisualMode';
import { cn } from '@/shared/lib/cn';
@@ -10,6 +14,7 @@ interface RoomsGalleryWidgetProps {
rooms: RoomTheme[];
selectedRoomId: string;
onRoomSelect: (roomId: string) => void;
startPanel: ReactNode;
}
export const RoomsGalleryWidget = ({
@@ -17,79 +22,148 @@ export const RoomsGalleryWidget = ({
rooms,
selectedRoomId,
onRoomSelect,
startPanel,
}: RoomsGalleryWidgetProps) => {
const [isCatalogOpen, setCatalogOpen] = useState(false);
const [carouselOffset, setCarouselOffset] = useState(0);
const cinematic = visualMode === 'cinematic';
const selectedRoom = rooms.find((room) => room.id === selectedRoomId) ?? rooms[0];
const { recommendedRooms, allRooms } = getHubRoomSections(rooms, selectedRoom.id, 5);
const { recommendedRooms, allRooms } = getHubRoomSections(rooms, selectedRoom.id, 6);
const canRotate = recommendedRooms.length > 1;
const rotatedRecommendedRooms = useMemo(() => {
if (recommendedRooms.length === 0) {
return [];
}
const normalizedOffset =
((carouselOffset % recommendedRooms.length) + recommendedRooms.length) %
recommendedRooms.length;
return [
...recommendedRooms.slice(normalizedOffset),
...recommendedRooms.slice(0, normalizedOffset),
];
}, [carouselOffset, recommendedRooms]);
useEffect(() => {
setCarouselOffset(0);
}, [selectedRoomId]);
return (
<section className="space-y-3.5">
<div className="flex items-end justify-between gap-3">
<div>
<p
className={cn(
'text-[11px] uppercase tracking-[0.14em]',
cinematic ? 'text-white/52' : 'text-brand-dark/48',
)}
>
Scene
</p>
<h2
className={cn(
'mt-1 text-xl font-semibold tracking-tight',
cinematic ? 'text-white' : 'text-brand-dark',
)}
>
</h2>
</div>
<button
type="button"
onClick={() => setCatalogOpen(true)}
<section className="space-y-3">
<div
className={cn(
'relative overflow-hidden rounded-[2rem] border',
cinematic
? 'border-white/16 bg-slate-950/34 shadow-[0_24px_70px_rgba(2,6,23,0.5)]'
: 'border-brand-dark/12 bg-white/78 shadow-[0_20px_64px_rgba(15,23,42,0.2)]',
)}
>
<div
aria-hidden
className="absolute inset-0"
style={getRoomCardBackgroundStyle(selectedRoom)}
/>
<div
aria-hidden
className={cn(
'text-xs font-medium transition-colors',
'absolute inset-0',
cinematic
? 'text-white/56 hover:text-white/84'
: 'text-brand-dark/56 hover:text-brand-dark/84',
? 'bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.44)_58%,rgba(2,6,23,0.84)_100%)]'
: 'bg-[linear-gradient(180deg,rgba(15,23,42,0.16)_0%,rgba(15,23,42,0.4)_56%,rgba(15,23,42,0.74)_100%)]',
)}
>
</button>
</div>
<RoomPreviewCard
room={selectedRoom}
visualMode={visualMode}
variant="hero"
selected
onSelect={onRoomSelect}
/>
<section className="space-y-2.5">
<h3
/>
<div
aria-hidden
className={cn(
'text-xs font-medium tracking-[0.06em]',
cinematic ? 'text-white/66' : 'text-brand-dark/60',
'absolute inset-0',
cinematic
? 'bg-[radial-gradient(circle_at_20%_12%,rgba(125,211,252,0.14),transparent_35%),radial-gradient(circle_at_78%_10%,rgba(196,181,253,0.13),transparent_36%)]'
: 'bg-[radial-gradient(circle_at_20%_12%,rgba(148,163,184,0.14),transparent_35%),radial-gradient(circle_at_78%_10%,rgba(125,211,252,0.1),transparent_36%)]',
)}
>
</h3>
<div className="-mx-1 snap-x overflow-x-auto px-1 sm:mx-0 sm:overflow-visible sm:px-0">
<div className="flex gap-3 sm:grid sm:grid-cols-5">
{recommendedRooms.map((room) => (
<RoomPreviewCard
key={`recommended-${room.id}`}
room={room}
visualMode={visualMode}
variant="compact"
selected={room.id === selectedRoomId}
onSelect={onRoomSelect}
/>
))}
/>
<div
aria-hidden
className="absolute inset-0 opacity-[0.16]"
style={{
backgroundImage:
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.02) 0 1px, transparent 1px 2px)",
mixBlendMode: 'soft-light',
}}
/>
<div className="relative z-10 flex min-h-[640px] flex-col px-4 py-4 sm:px-6 sm:py-6 lg:px-7 lg:py-7">
<header className="flex items-start gap-3">
<div>
<p className="text-[11px] uppercase tracking-[0.16em] text-white/58">Hub Stage</p>
<h2 className="mt-1 text-2xl font-semibold tracking-tight text-white"> </h2>
</div>
</header>
<div className="mt-auto space-y-4">
<div className="max-w-[560px] rounded-2xl border border-white/16 bg-slate-950/46 px-4 py-3 backdrop-blur-md sm:px-5 sm:py-4">
<p className="text-[11px] uppercase tracking-[0.18em] text-white/56">Selected Space</p>
<h3 className="mt-1 text-3xl font-semibold tracking-tight text-white sm:text-[2.25rem]">
{selectedRoom.name}
</h3>
<p className="mt-1.5 text-sm text-white/78 sm:text-base">{selectedRoom.description}</p>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)] lg:items-end">
<div className="lg:max-w-[360px]">{startPanel}</div>
<section className="rounded-2xl border border-white/14 bg-slate-950/44 p-3 backdrop-blur-md sm:p-3.5">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[11px] font-medium tracking-[0.12em] text-white/66"> </h3>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCatalogOpen(true)}
className="text-[11px] text-white/60 transition-colors hover:text-white/86"
>
</button>
<div className="flex items-center gap-1">
<button
type="button"
disabled={!canRotate}
onClick={() => setCarouselOffset((current) => current - 1)}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/20 bg-white/8 text-sm text-white/82 transition hover:bg-white/14 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="추천 공간 이전"
>
</button>
<button
type="button"
disabled={!canRotate}
onClick={() => setCarouselOffset((current) => current + 1)}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/20 bg-white/8 text-sm text-white/82 transition hover:bg-white/14 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="추천 공간 다음"
>
</button>
</div>
</div>
</div>
<div className="-mx-1 snap-x overflow-x-auto px-1 pb-1">
<div className="flex gap-2.5">
{rotatedRecommendedRooms.map((room) => (
<RoomPreviewCard
key={`recommended-${room.id}`}
room={room}
visualMode={visualMode}
variant="compact"
selected={room.id === selectedRoomId}
onSelect={onRoomSelect}
/>
))}
</div>
</div>
</section>
</div>
</div>
</div>
</section>
</div>
<RoomCatalogSheet
isOpen={isCatalogOpen}

View File

@@ -12,6 +12,7 @@ interface StartRitualWidgetProps {
onGoalChipSelect: (chip: GoalChip) => void;
onQuickEnter: () => void;
onOpenCustomEntry: () => void;
inStage?: boolean;
}
export const StartRitualWidget = ({
@@ -23,16 +24,20 @@ export const StartRitualWidget = ({
onGoalChipSelect,
onQuickEnter,
onOpenCustomEntry,
inStage = false,
}: StartRitualWidgetProps) => {
const cinematic = visualMode === 'cinematic';
const visibleGoalChips = inStage ? goalChips.slice(0, 2) : goalChips.slice(0, 3);
return (
<GlassCard
elevated
className={cn(
'space-y-4 p-4 sm:p-5',
inStage ? 'space-y-3 p-3.5 sm:p-4' : 'space-y-3.5 p-4 sm:p-5',
cinematic
? 'border-white/16 bg-slate-950/48 text-white shadow-[0_20px_44px_rgba(2,6,23,0.36)] backdrop-blur-xl'
? inStage
? 'border-white/18 bg-slate-950/62 text-white shadow-[0_18px_36px_rgba(2,6,23,0.36)] backdrop-blur-xl'
: 'border-white/16 bg-slate-950/48 text-white shadow-[0_20px_44px_rgba(2,6,23,0.36)] backdrop-blur-xl'
: 'border-brand-dark/10 bg-white/80 text-brand-dark shadow-[0_16px_40px_rgba(15,23,42,0.12)] backdrop-blur-md',
)}
>
@@ -43,11 +48,11 @@ export const StartRitualWidget = ({
cinematic ? 'text-white/48' : 'text-brand-dark/46',
)}
>
Start Ritual
{inStage ? 'Quick Entry' : 'Start Ritual'}
</p>
<h1
className={cn(
'text-2xl font-semibold tracking-tight',
inStage ? 'text-xl font-semibold tracking-tight' : 'text-2xl font-semibold tracking-tight',
cinematic ? 'text-white' : 'text-brand-dark',
)}
>
@@ -59,7 +64,7 @@ export const StartRitualWidget = ({
cinematic ? 'text-white/72' : 'text-brand-dark/68',
)}
>
. .
.
</p>
</div>
@@ -76,7 +81,7 @@ export const StartRitualWidget = ({
/>
<div className="flex flex-wrap gap-2">
{goalChips.slice(0, 4).map((chip) => (
{visibleGoalChips.map((chip) => (
<Chip
key={chip.id}
active={selectedGoalId === chip.id}
@@ -92,10 +97,17 @@ export const StartRitualWidget = ({
))}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
<div
className={cn(
'flex flex-col gap-2.5 sm:flex-row sm:items-center',
inStage ? 'sm:justify-start' : 'sm:justify-end',
)}
>
<Button
className={cn(
'w-full px-6 sm:w-auto sm:min-w-[176px]',
inStage
? 'w-full px-5 sm:min-w-[148px] sm:flex-1'
: 'w-full px-6 sm:w-auto sm:min-w-[168px]',
cinematic &&
'!bg-sky-200 !text-slate-900 shadow-[0_12px_24px_rgba(125,211,252,0.22)] hover:!bg-sky-100',
)}
@@ -107,14 +119,16 @@ export const StartRitualWidget = ({
type="button"
onClick={onOpenCustomEntry}
className={cn(
'inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-xl px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 sm:h-10 sm:w-auto',
inStage
? 'inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-xl px-3.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 sm:w-auto sm:min-w-[118px]'
: 'inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-xl px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 sm:h-10 sm:w-auto sm:min-w-[122px] sm:px-3.5',
cinematic
? 'text-white/70 hover:bg-white/8 hover:text-white'
: 'text-brand-dark/64 hover:bg-brand-dark/5 hover:text-brand-dark',
)}
>
<span aria-hidden></span>
<span> </span>
<span className="whitespace-nowrap"> </span>
</button>
</div>
</GlassCard>

View File

@@ -5,14 +5,45 @@ interface ThoughtSummaryEntryWidgetProps {
visualMode: AppHubVisualMode;
thoughtCount: number;
onOpen: () => void;
compact?: boolean;
}
export const ThoughtSummaryEntryWidget = ({
visualMode,
thoughtCount,
onOpen,
compact = false,
}: ThoughtSummaryEntryWidgetProps) => {
const cinematic = visualMode === 'cinematic';
const countLabel = thoughtCount > 99 ? '99+' : `${thoughtCount}`;
if (compact) {
return (
<button
type="button"
onClick={onOpen}
className={cn(
'group inline-flex h-10 items-center gap-2 rounded-full border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75',
cinematic
? 'border-white/22 bg-white/10 text-white/86 hover:bg-white/16'
: 'border-brand-dark/14 bg-white/72 text-brand-dark/80 hover:bg-white/90',
)}
>
<span aria-hidden className="text-sm"></span>
<span></span>
<span
className={cn(
'inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
cinematic
? 'bg-sky-200/20 text-sky-100'
: 'bg-brand-primary/14 text-brand-primary/86',
)}
>
{countLabel}
</span>
</button>
);
}
return (
<button
@@ -53,7 +84,7 @@ export const ThoughtSummaryEntryWidget = ({
: 'bg-brand-primary/14 text-brand-primary/86',
)}
>
{thoughtCount > 99 ? '99+' : `${thoughtCount}`}
{countLabel}
</span>
<span className={cn('text-xs transition-transform group-hover:translate-x-0.5', cinematic ? 'text-white/58' : 'text-brand-dark/52')}>