/** * 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 { fetchInventoryCategories, searchInventory, deleteInventoryItem, } from '../inventoryApi'; import { PersonaDocumentsPanel } from './PersonaDocumentsPanel'; // --------------------------------------------------------------------------- // Sensitivity helpers (mirrors backend inventory.py) // --------------------------------------------------------------------------- const SENS_ORDER = { safe: 0, sensitive: 1, explicit: 2 }; function classifySensitivity(label) { 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, maxSens) { return (SENS_ORDER[itemSens] ?? 0) <= (SENS_ORDER[maxSens] ?? 0); } // --------------------------------------------------------------------------- // Build InventoryItem[] from local draft state // --------------------------------------------------------------------------- function buildDraftItems(draft, sensitivityMax) { const out = []; 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 = { outfit: Shirt, image: ImageIcon, file: FileText, all: Package, }; const CATEGORY_COLORS = { 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 }) { // 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(() => { const extrasByType = {}; 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) { 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) { 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) => { 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) => { 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) { 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) => { 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)}/>))}
); // ----------------------------------------------------------------------- // Render: item card // ----------------------------------------------------------------------- const renderItemCard = (item) => { 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.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, }) { return (); } // --------------------------------------------------------------------------- // Action button (hover overlay) // --------------------------------------------------------------------------- function ActionButton({ icon: Icon, label, onClick, variant, }) { const isDanger = variant === 'danger'; return (); }