/** * InventoryView — MMORPG-style inventory management panel. * * Replaces the PersonaSettingsPanel content when viewMode="inventory". * Two-pane layout: * Left: category sidebar with counts * Right: search bar + item grid with lazy loading * * Mental model: A persona has exactly ONE "Active Look" — the image that * represents them everywhere. Inventory is a gallery of stored images; * any image can be promoted to Active Look via a single click. Thumbnails * are derived automatically and never shown as selectable items. */ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { ArrowLeft, Search, Shirt, Image as ImageIcon, FileText, Loader2, Eye, Lock, Package, AlertCircle, Trash2, RotateCcw, } from 'lucide-react' import type { InventoryCategory, InventoryItem } from '../inventoryApi' import { fetchInventoryCategories, searchInventory, deleteInventoryItem, } from '../inventoryApi' import { PersonaDocumentsPanel } from './PersonaDocumentsPanel' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type Props = { projectId: string backendUrl: string apiKey?: string onBack: () => void /** Called when user clicks "Show in chat" with a resolved image URL */ onShowInChat?: (item: InventoryItem, resolvedUrl: string) => void /** Currently selected image (from PersonaSettingsPanel selectedImage state). * Used to draw the amber "Equipped" border on the matching inventory card. */ activeSelection?: { set_id: string; image_id: string } | null /** Called when user selects an image as the persona's Active Look. * Passes the persona_appearance set_id + image_id for wardrobe-style selection. */ onSetActiveLook?: (selection: { set_id: string; image_id: string }) => void /** Draft persona appearance from local state so newly generated * images appear in inventory before they are saved to backend. */ draftAppearance?: DraftAppearance } type SelectedCategory = 'outfit' | 'image' | 'file' | 'all' // --------------------------------------------------------------------------- // Draft appearance type — local state passed from PersonaSettingsPanel // so generated images appear in inventory before they are saved to backend. // --------------------------------------------------------------------------- type DraftAppearance = { sets: Array<{ set_id: string; images: Array<{ id: string; url: string; set_id: string }> }> outfits: Array<{ id: string label: string outfit_prompt?: string images: Array<{ id: string; url: string; set_id: string }> }> selected: { set_id: string; image_id: string } | null } // --------------------------------------------------------------------------- // Sensitivity helpers (mirrors backend inventory.py) // --------------------------------------------------------------------------- const SENS_ORDER: Record = { safe: 0, sensitive: 1, explicit: 2 } function classifySensitivity(label: string): 'safe' | 'sensitive' { const low = (label || '').trim().toLowerCase() const keywords = ['lingerie', 'intimate', 'underwear', 'bra', 'panties', 'sexy', 'bikini'] return keywords.some(k => low.includes(k)) ? 'sensitive' : 'safe' } function allowedBySensitivity(itemSens: string, maxSens: string): boolean { return (SENS_ORDER[itemSens] ?? 0) <= (SENS_ORDER[maxSens] ?? 0) } // --------------------------------------------------------------------------- // Build InventoryItem[] from local draft state // --------------------------------------------------------------------------- function buildDraftItems( draft: DraftAppearance, sensitivityMax: string, ): InventoryItem[] { const out: InventoryItem[] = [] const active = draft.selected // Base portraits from sets → type='image' for (const s of draft.sets) { for (const img of s.images) { if (!img.url) continue if (!allowedBySensitivity('safe', sensitivityMax)) continue out.push({ id: img.id, type: 'image', label: 'Portrait', tags: ['portrait', 'set'], sensitivity: 'safe', url: img.url, set_id: s.set_id, image_id: img.id, is_active_look: !!(active && s.set_id === active.set_id && img.id === active.image_id), }) } } // Outfits → type='outfit' with preview image (mirrors backend search output) for (const outfit of draft.outfits) { const sens = classifySensitivity(outfit.label) if (!allowedBySensitivity(sens, sensitivityMax)) continue const firstImg = outfit.images[0] out.push({ id: outfit.id, type: 'outfit', label: outfit.label, tags: [outfit.label.toLowerCase()], sensitivity: sens, description: outfit.outfit_prompt, asset_ids: outfit.images.map(img => img.id), preview_asset_id: firstImg?.id, url: firstImg?.url, // set_id/image_id from first image so the outfit card is selectable set_id: firstImg ? outfit.id : undefined, image_id: firstImg?.id, is_active_look: !!(active && firstImg && outfit.id === active.set_id && firstImg.id === active.image_id), }) } return out } // --------------------------------------------------------------------------- // Category icon map // --------------------------------------------------------------------------- const CATEGORY_ICONS: Record = { outfit: Shirt, image: ImageIcon, file: FileText, all: Package, } const CATEGORY_COLORS: Record = { outfit: 'text-amber-400', image: 'text-pink-400', file: 'text-blue-400', all: 'text-purple-400', } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export function InventoryView({ projectId, backendUrl, apiKey, onBack, onShowInChat, activeSelection, onSetActiveLook, draftAppearance }: Props) { // State const [categories, setCategories] = useState([]) const [items, setItems] = useState([]) const [totalCount, setTotalCount] = useState(0) const [loading, setLoading] = useState(true) const [searchLoading, setSearchLoading] = useState(false) const [error, setError] = useState(null) const [selectedCategory, setSelectedCategory] = useState('all') const [query, setQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState('') const [limit] = useState(30) const [refreshKey, setRefreshKey] = useState(0) const [deleting, setDeleting] = useState(null) const searchTimerRef = useRef>() // Sensitivity from localStorage (matches PersonaSettingsPanel) const isSpicy = (() => { try { return localStorage.getItem('homepilot_nsfw_mode') === 'true' } catch { return false } })() const sensitivityMax = isSpicy ? 'explicit' : 'safe' // ----------------------------------------------------------------------- // Draft items from local state (appear before backend save) // ----------------------------------------------------------------------- const draftItems = useMemo(() => { if (!draftAppearance) return [] return buildDraftItems(draftAppearance, sensitivityMax) }, [draftAppearance, sensitivityMax]) // Merge backend items + draft extras (backend wins on ID conflicts) const mergedItems = useMemo(() => { const backendIds = new Set(items.map(i => i.id)) const q = debouncedQuery.toLowerCase() const extras = draftItems.filter(d => { if (backendIds.has(d.id)) return false if (selectedCategory !== 'all' && d.type !== selectedCategory) return false if (q) { const label = (d.label || '').toLowerCase() const desc = (d.description || '').toLowerCase() const tags = (d.tags || []).join(' ').toLowerCase() if (!label.includes(q) && !desc.includes(q) && !tags.includes(q)) return false } return true }) return [...items, ...extras] }, [items, draftItems, selectedCategory, debouncedQuery]) // Merged category counts: backend counts + genuinely new draft items // (items with uncommitted URLs that aren't in the backend yet). const mergedCategories = useMemo((): InventoryCategory[] => { const extrasByType: Record = {} const backendIds = new Set(items.map(i => i.id)) for (const d of draftItems) { if (backendIds.has(d.id)) continue // Items with /files/ URLs are already committed — avoid double-count const url = d.url || '' if (url.includes('/files/')) continue if (!url) continue extrasByType[d.type] = (extrasByType[d.type] || 0) + 1 } if (categories.length > 0) { return categories.map(cat => ({ ...cat, count: (cat.count ?? 0) + (extrasByType[cat.type] ?? 0), })) } return [ { type: 'outfit', label: 'Outfits', count: extrasByType.outfit ?? 0 }, { type: 'image', label: 'Photos', count: extrasByType.image ?? 0 }, { type: 'file', label: 'Documents', count: extrasByType.file ?? 0 }, ] }, [categories, items, draftItems]) // ----------------------------------------------------------------------- // Debounced search // ----------------------------------------------------------------------- useEffect(() => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current) searchTimerRef.current = setTimeout(() => setDebouncedQuery(query), 300) return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current) } }, [query]) // ----------------------------------------------------------------------- // Load categories on mount // ----------------------------------------------------------------------- useEffect(() => { let cancelled = false ;(async () => { try { const data = await fetchInventoryCategories(backendUrl, projectId, { apiKey, includeTags: true, sensitivityMax, }) if (!cancelled) setCategories(data.categories || []) } catch (e: any) { if (!cancelled) setError(e.message) } })() return () => { cancelled = true } }, [backendUrl, projectId, apiKey, sensitivityMax, refreshKey]) // ----------------------------------------------------------------------- // Search when category or query changes // ----------------------------------------------------------------------- useEffect(() => { let cancelled = false ;(async () => { setSearchLoading(true) try { const types = selectedCategory === 'all' ? undefined : [selectedCategory] const data = await searchInventory(backendUrl, projectId, { apiKey, query: debouncedQuery || undefined, types, limit, sensitivityMax, }) if (!cancelled) { setItems(data.items || []) setTotalCount(data.total_count || 0) setError(null) } } catch (e: any) { if (!cancelled) setError(e.message) } finally { if (!cancelled) { setSearchLoading(false) setLoading(false) } } })() return () => { cancelled = true } }, [backendUrl, projectId, apiKey, selectedCategory, debouncedQuery, limit, sensitivityMax, refreshKey]) // ----------------------------------------------------------------------- // Resolve image URL for display // ----------------------------------------------------------------------- const resolveImageUrl = useCallback((item: InventoryItem): string | undefined => { const url = item.url if (!url) return undefined const tok = (() => { try { return localStorage.getItem('homepilot_auth_token') || '' } catch { return '' } })() let full = url.startsWith('http') ? url : `${backendUrl}${url}` if (tok && full.includes('/files/')) { const sep = full.includes('?') ? '&' : '?' full = `${full}${sep}token=${encodeURIComponent(tok)}` } return full }, [backendUrl]) // ----------------------------------------------------------------------- // Delete handler // ----------------------------------------------------------------------- const handleDelete = useCallback(async (item: InventoryItem) => { if (deleting) return // Prevent deleting the active look from the UI side if (item.is_active_look) { setError('Cannot delete the active look. Change the active look first.') return } if (!confirm(`Delete "${item.label}"? This cannot be undone.`)) return setDeleting(item.id) try { await deleteInventoryItem(backendUrl, projectId, item.id, { apiKey }) setRefreshKey((k) => k + 1) } catch (e: any) { const msg = e.message || '' if (msg.includes('409') || msg.includes('active look')) { setError('Cannot delete the active look. Change the active look first.') } else { setError(`Delete failed: ${msg}`) } } finally { setDeleting(null) } }, [backendUrl, projectId, apiKey, deleting]) // ----------------------------------------------------------------------- // Set as Active Look handler (wardrobe-style selection) // ----------------------------------------------------------------------- const handleSetActiveLook = useCallback((item: InventoryItem) => { if (!item.set_id || !item.image_id) return onSetActiveLook?.({ set_id: item.set_id, image_id: item.image_id }) }, [onSetActiveLook]) // ----------------------------------------------------------------------- // Render: category sidebar // ----------------------------------------------------------------------- const totalItems = mergedCategories.reduce((sum, c) => sum + (c.count || 0), 0) const renderSidebar = () => (
{/* All items */} setSelectedCategory('all')} /> {/* Per-category */} {mergedCategories.map((cat) => ( setSelectedCategory(cat.type as SelectedCategory)} /> ))}
) // ----------------------------------------------------------------------- // Render: item card // ----------------------------------------------------------------------- const renderItemCard = (item: InventoryItem) => { const isImage = item.type === 'image' const isOutfit = item.type === 'outfit' const isFile = item.type === 'file' const isSensitive = item.sensitivity === 'sensitive' || item.sensitivity === 'explicit' const imgUrl = (isImage || isOutfit) ? resolveImageUrl(item) : undefined const canSetActive = !!(item.set_id && item.image_id && onSetActiveLook) // Wardrobe-style: check if this card matches the current selection const isSelected = !!( activeSelection && item.set_id === activeSelection.set_id && item.image_id === activeSelection.image_id ) const isDeleting = deleting === item.id return (
{/* Equipped badge — Wardrobe style (top-right, always visible when selected or outfit is equipped) */} {(isSelected || (isOutfit && item.equipped)) && (
Equipped
)} {/* Delete button — top-right on hover (shifts left when Equipped badge is showing) */}
handleDelete(item)} variant="danger" />
{/* Image preview — primary click selects (like Wardrobe) */} {(isImage || isOutfit) && (
{ if (canSetActive) { handleSetActiveLook(item) } }} > {isSensitive && !isSpicy ? (
Locked
) : imgUrl ? ( {item.label} { (e.target as HTMLImageElement).style.display = 'none' }} /> ) : (
)} {/* Hover actions — bottom center (only Show in chat) */}
{onShowInChat && imgUrl && ( onShowInChat(item, imgUrl)} /> )}
)} {/* File icon for documents */} {isFile && (
{(item.mime || '').split('/').pop() || 'file'}
)} {/* Label + metadata */}
{item.label}
{item.type} {isSensitive && ( {item.sensitivity} )} {/* 360 preview badge for outfits with view packs */} {isOutfit && item.interactive_preview && ( 360 )}
{/* Tags */} {item.tags && item.tags.length > 0 && (
{item.tags.slice(0, 3).map((tag) => ( {tag} ))}
)} {/* View angle chips for outfits with view packs */} {isOutfit && item.available_views && item.available_views.length > 0 && (
{item.available_views.map((angle) => ( {angle} ))}
)} {isFile && item.size_bytes != null && item.size_bytes > 0 && (
{item.size_bytes > 1048576 ? `${(item.size_bytes / 1048576).toFixed(1)} MB` : `${Math.round(item.size_bytes / 1024)} KB`}
)}
) } // ----------------------------------------------------------------------- // Render: loadout summary (compact, top of content) // ----------------------------------------------------------------------- const activeLookItem = mergedItems.find((i) => i.is_active_look) const outfitCount = mergedCategories.find((c) => c.type === 'outfit')?.count || 0 const photoCount = mergedCategories.find((c) => c.type === 'image')?.count || 0 const docCount = mergedCategories.find((c) => c.type === 'file')?.count || 0 const renderLoadoutSummary = () => (
{/* Mini Active Look */} {activeLookItem?.url && (
Active Look
)}
{outfitCount} outfits {photoCount} photos {docCount} docs
) // ----------------------------------------------------------------------- // Main render // ----------------------------------------------------------------------- if (loading && items.length === 0) { return (
) } return (
{/* Top bar: back + search */}
setQuery(e.target.value)} placeholder="Search inventory..." className="w-full pl-9 pr-3 py-1.5 text-xs bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-white/25 focus:outline-none focus:border-white/20 transition-colors" />
{searchLoading ? ( ) : ( `${mergedItems.length} items` )}
{/* Loadout summary */} {renderLoadoutSummary()} {/* Two-pane body */}
{/* Sidebar */} {renderSidebar()} {/* Item grid */}
{error && (
{error}
)} {/* Knowledge Base panel (shown when Documents category is selected) */} {selectedCategory === 'file' && (
setRefreshKey((k) => k + 1)} />
)} {mergedItems.length === 0 && !searchLoading && selectedCategory !== 'file' && (

{debouncedQuery ? 'No items match your search.' : totalItems > 0 && mergedItems.length === 0 ? 'No items returned. Check sensitivity filters or try refreshing.' : 'Inventory is empty.'}

)} {/* Hide inventory grid when Documents tab is active — the Knowledge Base panel above is the single source of truth */} {selectedCategory !== 'file' && (
{mergedItems.map(renderItemCard)}
)} {selectedCategory !== 'file' && items.length < totalCount && !searchLoading && (
Showing {mergedItems.length} of {totalCount + (mergedItems.length - items.length)} items
)}
) } // --------------------------------------------------------------------------- // Sidebar item // --------------------------------------------------------------------------- function SidebarItem({ icon: Icon, label, count, color, active, onClick, }: { icon: React.ElementType label: string count?: number color: string active: boolean onClick: () => void }) { return ( ) } // --------------------------------------------------------------------------- // Action button (hover overlay) // --------------------------------------------------------------------------- function ActionButton({ icon: Icon, label, onClick, variant, }: { icon: React.ElementType label: string onClick: () => void variant?: 'default' | 'danger' }) { const isDanger = variant === 'danger' return ( ) }