diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index e21af327..a6a3ba49 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -3,11 +3,10 @@ import { createPortal } from 'react-dom'; import { Link, useNavigate } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; -import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, HeartHandshake, Package, Target, Droplets, Heart, Zap } from 'lucide-react'; +import { Egg, Moon, Sun, RefreshCw, Check, Plus, Camera, AlertTriangle, Footprints, Wrench, Theater, ExternalLink, Utensils, Gamepad2, Sparkles, Pill, Music, Mic, Loader2, Target, Droplets, Heart, Zap } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState'; -import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay'; import { useAppContext } from '@/hooks/useAppContext'; import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile'; import { useBlobbonautProfileNormalization } from '@/hooks/useBlobbonautProfileNormalization'; @@ -47,7 +46,6 @@ import { import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay'; import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items'; -import type { ShopItem } from '@/blobbi/shop/types/shop.types'; import { BlobbiActionInventoryModal, @@ -89,6 +87,20 @@ import { useBlobbiActionsRegistration, type UseItemFunction } from '@/blobbi/com import { BlobbiDevEditor, useBlobbiDevUpdate, type BlobbiDevUpdates, BlobbiEmotionPanel, useEffectiveEmotion, isLocalhostDev } from '@/blobbi/dev'; import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction'; import { buildSleepingRecipe } from '@/blobbi/ui/lib/recipe'; +import { + BlobbiRoomShell, + BlobbiRoomHero, + ItemCarousel, + RoomActionButton, + type BlobbiRoomId, + type CarouselEntry, + type PoopState, + isValidRoomId, + DEFAULT_INITIAL_ROOM, + getPoopsInRoom, + hasAnyPoop, +} from '@/blobbi/rooms'; +import { ROOM_BOTTOM_BAR_CLASS } from '@/blobbi/rooms/lib/room-layout'; import { getActionEmotion, type ActionType } from '@/blobbi/ui/lib/status-reactions'; import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions'; @@ -122,14 +134,7 @@ function companionNeedsCare(companion: BlobbiCompanion): boolean { ); } -/** Map stat keys to indicator colors */ -const STAT_COLOR_MAP: Record = { - hunger: 'orange', - happiness: 'yellow', - health: 'green', - hygiene: 'blue', - energy: 'violet', -}; + // ─── Page Component ─────────────────────────────────────────────────────────── @@ -814,8 +819,8 @@ function DashboardShell({ children }: DashboardShellProps) { // ─── Dashboard Drawer Type ──────────────────────────────────────────────────── -/** Which drawer is open; 'none' = all closed */ -type DashboardDrawer = 'none' | 'care' | 'items' | 'missions' | 'more'; +/** Which drawer is open; 'none' = room view visible */ +type DashboardDrawer = 'none' | 'missions' | 'more'; // ─── Main Blobbi Dashboard ──────────────────────────────────────────────────── @@ -895,7 +900,13 @@ function BlobbiDashboard({ // ─── Active Drawer ─── const isMobile = useIsMobile(); - const [activeDrawer, setActiveDrawer] = useState(isMobile ? 'none' : 'care'); + const [activeDrawer, setActiveDrawer] = useState('none'); + + // ─── Room Navigation ─── + const [currentRoom, setCurrentRoom] = useState( + isValidRoomId(profile?.room) ? profile.room : DEFAULT_INITIAL_ROOM, + ); + const poopStateRef = useRef(null); // Toggle drawer: tapping same tab closes it, tapping another opens that one const toggleDrawer = useCallback((drawer: DashboardDrawer) => { @@ -1353,10 +1364,9 @@ function BlobbiDashboard({ } }; - // Handle opening shop from empty state (switches to items drawer) + // Handle opening shop from empty state const handleOpenShopFromAction = () => { setInventoryAction(null); - setActiveDrawer('items'); }; // ─── Daily Missions (for missions tab) ─── @@ -1392,34 +1402,14 @@ function BlobbiDashboard({ /> )} - {/* ─── Drawer + Tab Bar — overlays the hero ─── */} + {/* ─── Drawer + Tab Bar — overlays the room ─── */}
- {/* Sliding drawer — in flow, pushes tabs down when open */}
- {activeDrawer === 'care' && ( - setActiveDrawer('items')} - onDirectAction={handleDirectAction} - onRest={onRest} - actionInProgress={actionInProgress} - isPublishing={isPublishing} - /> - )} - {activeDrawer === 'items' && ( - - )} {activeDrawer === 'missions' && ( setShowPostModal(true)} - dailyMissions={dailyMissions} + dailyMissions={dailyMissions} canStartIncubation={canStartIncubation} canStartEvolution={canStartEvolution} isStartingIncubation={isStartingIncubation} @@ -1466,21 +1456,8 @@ function BlobbiDashboard({
- {/* The arc tab bar — below the drawer */} - toggleDrawer('care')}> - - - Care - - - toggleDrawer('items')}> - - - Items - - - toggleDrawer('missions')}> + toggleDrawer('missions')}> Quests @@ -1495,209 +1472,88 @@ function BlobbiDashboard({
- {/* ─── Hero Section (always visible below drawer) ─── */} -
- - - {/* Main Blobbi Visual with stats crown + action buttons */} - {isActiveFloatingCompanion ? ( -
- -

- {companion.name} is out exploring right now. -

- -
- ) : ( -
- {/* Stats crown — arced above the Blobbi */} - {(() => { - const allStats = getVisibleStats(companion.stage).map(stat => ({ - stat, - value: currentStats[stat] ?? 100, - status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100), - color: STAT_COLOR_MAP[stat], - })); - if (allStats.length === 0) return null; - - const count = allStats.length; - const isSmall = heroWidth < 400; - const arcSpread = isSmall - ? (count <= 2 ? 90 : count <= 3 ? 130 : 160) - : (count <= 2 ? 80 : count <= 3 ? 120 : 160); - const arcHalf = arcSpread / 2; - const angles = count === 1 - ? [0] - : allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i); - - return ( -
- {allStats.map((s, i) => { - const angleDeg = angles[i]; - const angleRad = (angleDeg * Math.PI) / 180; - // Scale radius based on container width: ~140 at 340px, ~210 at 640px+ - const radius = Math.min(210, Math.max(140, (heroWidth - 340) / (640 - 340) * (210 - 140) + 140)); - const x = Math.sin(angleRad) * radius; - // Inverted: center (cos=1) is highest, edges droop down - const y = Math.cos(angleRad) * radius - radius; - - return ( -
- -
- ); - })} -
- ); - })()} - - {/* Blobbi visual */} -
-
- -
- - {/* Blobbi Name — hidden for eggs */} - {!isEgg && ( -

- {companion.name} -

- )} -
- )} - - {/* ── Action circles — below the Blobbi ── */} - {!isActiveFloatingCompanion && ( -
- {/* Photo — lower left */} - - - {/* Companion — lower right */} - {canBeCompanion && ( - )} -
+ {inlineActivity.type === 'sing' && ( +
+ +
+ )} + + } + > + {/* Per-room bottom bar */} + {!isActiveFloatingCompanion && ( + )} -
- - {/* ─── Inline Activity Area (music/sing — floats above tabs) ─── */} - {inlineActivity.type === 'music' && ( -
- -
- )} - {inlineActivity.type === 'sing' && ( -
- -
- )} - - {/* Tab content is now rendered in the drawer above */} + {/* ─── Dialogs (only for things that genuinely need modals) ─── */} @@ -1796,77 +1652,324 @@ function BlobbiDashboard({ ); } -// ─── Care Tab Content ───────────────────────────────────────────────────────── +// ─── Room Bottom Bar ────────────────────────────────────────────────────────── -interface CareTabContentProps { +interface RoomBottomBarProps { + room: BlobbiRoomId; + companion: BlobbiCompanion; + profile: BlobbonautProfile | null; isEgg: boolean; isSleeping: boolean; - onOpenItems: () => void; - onDirectAction: (action: DirectAction) => void; - onRest: () => void; - actionInProgress: string | null; + isUsingItem: boolean; + usingItemId: string | null; isPublishing: boolean; + actionInProgress: string | null; + isDirectActionPending: boolean; + isCurrentCompanion: boolean; + canBeCompanion: boolean; + isUpdatingCompanion: boolean; + handleSetAsCompanion: () => Promise; + handleUseItemFromTab: (itemId: string) => void; + handleDirectAction: (action: DirectAction) => void; + onUseItem: (itemId: string, action: InventoryAction) => Promise; + onRest: () => void; + setShowPhotoModal: React.Dispatch>; + setInventoryAction: React.Dispatch>; + poopStateRef: React.MutableRefObject; } -function CareTabContent({ - isEgg, - isSleeping, - onOpenItems, - onDirectAction, - onRest, - actionInProgress, +function RoomBottomBar(props: RoomBottomBarProps) { + switch (props.room) { + case 'home': return ; + case 'kitchen': return ; + case 'care': return ; + case 'rest': return ; + case 'closet': return ; + } +} + +// ── Home: toys + music/sing, photo left, companion right ── + +function HomeBar({ + isUsingItem, + usingItemId, isPublishing, -}: CareTabContentProps) { - const isDisabled = isPublishing || actionInProgress !== null; + actionInProgress, + isCurrentCompanion, + canBeCompanion, + isUpdatingCompanion, + handleSetAsCompanion, + handleUseItemFromTab, + handleDirectAction, + setShowPhotoModal, +}: RoomBottomBarProps) { + const carouselItems = useMemo(() => { + const toys = getLiveShopItems() + .filter(i => i.type === 'toy') + .map(i => ({ id: i.id, icon: {i.icon}, label: i.name })); + return [ + ...toys, + { + id: '__action_music', + icon:
, + label: 'Music', + }, + { + id: '__action_sing', + icon:
, + label: 'Sing', + }, + ]; + }, []); + + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + + const handleCarouselUse = useCallback((id: string) => { + if (id === '__action_music') handleDirectAction('play_music'); + else if (id === '__action_sing') handleDirectAction('sing'); + else handleUseItemFromTab(id); + }, [handleDirectAction, handleUseItemFromTab]); return ( -
-
- } - statIcon={} - label="Items" - description="Feed, clean & heal" - color="text-sky-500" - onClick={onOpenItems} - disabled={isDisabled} - /> - - } - statIcon={} - label="Music" - description="Play a tune" +
+
+ } + label="Photo" color="text-pink-500" - onClick={() => onDirectAction('play_music')} - disabled={isDisabled} + glowHex="#ec4899" + onClick={() => setShowPhotoModal(true)} /> +
+ +
+ {canBeCompanion ? ( + } + label={isCurrentCompanion ? 'With you' : 'Take along'} + color={isCurrentCompanion ? 'text-emerald-500' : 'text-violet-500'} + glowHex={isCurrentCompanion ? '#10b981' : '#8b5cf6'} + onClick={handleSetAsCompanion} + disabled={isUpdatingCompanion} + loading={isUpdatingCompanion} + /> + ) : ( +
+ )} +
+
+ ); +} - } - statIcon={} - label="Sing" - description="Sing together" - color="text-purple-500" - onClick={() => onDirectAction('sing')} - disabled={isDisabled} +// ── Kitchen: food carousel, shovel left (if poop), fridge right ── + +function KitchenBar({ + companion, + profile, + isUsingItem, + usingItemId, + isPublishing, + actionInProgress, + onUseItem, + setInventoryAction, + poopStateRef, +}: RoomBottomBarProps) { + const [showFridge, setShowFridge] = useState(false); + const poopState = poopStateRef.current; + + const foodEntries = useMemo(() => + getLiveShopItems() + .filter(i => i.type === 'food') + .map(i => ({ id: i.id, icon: {i.icon}, label: i.name })), + []); + + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + const kitchenPoops = poopState ? getPoopsInRoom(poopState.poops, 'kitchen') : []; + const anyPoop = poopState ? hasAnyPoop(poopState.poops) : false; + + const handleFridgeUseItem = useCallback((itemId: string) => { + if (isUsingItem) return; + onUseItem(itemId, 'feed').finally(() => setShowFridge(false)); + }, [isUsingItem, onUseItem]); + + return ( + <> + {/* Poop display (above bottom bar, inside room area) */} + {kitchenPoops.map((poop) => ( + + ))} + +
+
+ {anyPoop && poopState ? ( + 🧹} + label={poopState.shovelMode ? 'Done' : 'Shovel'} + color={poopState.shovelMode ? 'text-amber-600' : 'text-stone-500'} + glowHex={poopState.shovelMode ? '#d97706' : '#78716c'} + onClick={() => poopState.setShovelMode(prev => !prev)} + className={poopState.shovelMode ? 'ring-2 ring-amber-500/40 ring-offset-2 ring-offset-background rounded-full' : ''} + /> + ) : ( +
+ )} +
+ handleFridgeUseItem(id)} + activeItemId={isUsingItem ? usingItemId : null} + disabled={isDisabled} + /> +
+ 🧊} + label="Fridge" + color="text-orange-500" + glowHex="#f97316" + onClick={() => setInventoryAction('feed')} + disabled={isDisabled} + /> +
+
+ + {showFridge && ( + setShowFridge(false)} + isUsingItem={isUsingItem} + usingItemId={usingItemId} /> + )} + + ); +} +// ── Care: hygiene + medicine carousel, context-sensitive side buttons ── + +function CareBar({ + isUsingItem, + usingItemId, + isPublishing, + actionInProgress, + handleUseItemFromTab, +}: RoomBottomBarProps) { + const hygieneItems = useMemo(() => getLiveShopItems().filter(i => i.type === 'hygiene'), []); + + const carouselEntries = useMemo(() => { + const hygiene = getLiveShopItems() + .filter(i => i.type === 'hygiene' && i.id !== 'hyg_towel') + .map(i => ({ id: i.id, icon: {i.icon}, label: i.name, meta: 'hygiene' })); + const medicine = getLiveShopItems() + .filter(i => i.type === 'medicine') + .map(i => ({ id: i.id, icon: {i.icon}, label: i.name, meta: 'medicine' })); + return [...hygiene, ...medicine]; + }, []); + + const [focusedMeta, setFocusedMeta] = useState(carouselEntries[0]?.meta ?? 'hygiene'); + const handleFocusChange = useCallback((entry: CarouselEntry) => setFocusedMeta(entry.meta ?? 'hygiene'), []); + const isHygieneFocused = focusedMeta === 'hygiene'; + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + const towelItem = hygieneItems.find(i => i.id === 'hyg_towel'); + + return ( +
+
+ {isHygieneFocused ? ( + towelItem ? ( + {towelItem.icon}} + label="Towel" + color="text-cyan-500" + glowHex="#06b6d4" + onClick={() => handleUseItemFromTab(towelItem.id)} + disabled={isDisabled} + loading={isUsingItem && usingItemId === towelItem.id} + /> + ) : ( +
+ ) + ) : ( + 🍬} + label="Treat" + color="text-pink-400" + glowHex="#f472b6" + onClick={() => { + const treat = getLiveShopItems().find(i => i.type === 'food'); + if (treat) handleUseItemFromTab(treat.id); + }} + disabled={isDisabled} + /> + )} +
+ +
+ {isHygieneFocused ? ( + 🚿} + label="Shower" + color="text-blue-500" + glowHex="#3b82f6" + onClick={() => { + const shampoo = hygieneItems.find(i => i.id === 'hyg_shampoo'); + if (shampoo) handleUseItemFromTab(shampoo.id); + }} + disabled={isDisabled} + /> + ) : ( +
+ )} +
+
+ ); +} + +// ── Rest: sleep/wake button centered ── + +function RestBar({ isEgg, isSleeping, onRest, isPublishing, actionInProgress, isUsingItem }: RoomBottomBarProps) { + const isDisabled = isPublishing || actionInProgress !== null || isUsingItem; + + return ( +
+
{!isEgg && ( - - ) : isSleeping ? ( - - ) : ( - - ) + actionInProgress === 'rest' + ? + : isSleeping + ? + : } - statIcon={} - label={isSleeping ? 'Wake' : 'Sleep'} - description={isSleeping ? 'Rise and shine' : 'Rest & recharge'} + label={isSleeping ? 'Wake up' : 'Sleep'} color={isSleeping ? 'text-amber-500' : 'text-violet-500'} + glowHex={isSleeping ? '#f59e0b' : '#8b5cf6'} onClick={onRest} disabled={isDisabled} /> @@ -1876,111 +1979,14 @@ function CareTabContent({ ); } -// ─── Care Action Button ─────────────────────────────────────────────────────── +// ── Closet: placeholder ── -function CareActionButton({ - icon, - statIcon, - label, - description, - color, - onClick, - disabled, -}: { - icon: React.ReactNode; - statIcon: React.ReactNode; - label: string; - description: string; - color: string; - onClick: () => void; - disabled?: boolean; -}) { +function ClosetBar() { return ( - - ); -} - -// ─── Items Tab Content ──────────────────────────────────────────────────────── - -/** Lucide icon + color for each item category */ -function ItemTypeIndicator({ type }: { type: string }) { - switch (type) { - case 'food': - return ; - case 'toy': - return ; - case 'medicine': - return ; - case 'hygiene': - return ; - default: - return null; - } -} - -interface ItemsTabContentProps { - allShopItems: ShopItem[]; - onUseItem: (itemId: string) => void; - isUsingItem: boolean; - usingItemId: string | null; -} - -function ItemsTabContent({ - allShopItems, - onUseItem, - isUsingItem, - usingItemId, -}: ItemsTabContentProps) { - return ( -
- {allShopItems.filter(i => i.status !== 'disabled').map((item) => { - const isThisUsing = isUsingItem && usingItemId === item.id; - return ( - - ); - })}
); } @@ -2438,84 +2444,6 @@ function MoreTabContent({ ); } -// ─── Stat Indicator ─────────────────────────────────────────────────────────── - -interface StatIndicatorProps { - stat: string; - value: number | undefined; - color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet'; - status?: 'normal' | 'warning' | 'critical'; -} - -const STAT_COLORS: Record = { - orange: 'text-orange-500', - yellow: 'text-yellow-500', - green: 'text-green-500', - blue: 'text-blue-500', - violet: 'text-violet-500', -}; - -const STAT_BG_COLORS: Record = { - orange: 'bg-orange-500/10', - yellow: 'bg-yellow-500/10', - green: 'bg-green-500/10', - blue: 'bg-blue-500/10', - violet: 'bg-violet-500/10', -}; - -const STAT_RING_HEX: Record = { - orange: '#f97316', - yellow: '#eab308', - green: '#22c55e', - blue: '#3b82f6', - violet: '#8b5cf6', -}; - -/** Lucide icon component for each stat */ -const STAT_ICON_MAP: Record> = { - hunger: Utensils, - happiness: Gamepad2, - health: Heart, - hygiene: Droplets, - energy: Zap, -}; - -function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorProps) { - const displayValue = value ?? 0; - const isLow = status === 'warning' || status === 'critical'; - const ringHex = STAT_RING_HEX[color]; - const IconComponent = STAT_ICON_MAP[stat]; - - return ( -
- {/* Progress ring */} - - - - - {/* Icon with warning badge on its corner */} -
- {IconComponent && } - {isLow && ( - - )} -
-
- ); -} - // ─── Blobbi Selector Page ───────────────────────────────────────────────────── interface BlobbiSelectorPageProps {