/** * 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 = () => (
{debouncedQuery ? 'No items match your search.' : totalItems > 0 && mergedItems.length === 0 ? 'No items returned. Check sensitivity filters or try refreshing.' : 'Inventory is empty.'}