A Blazor Razor UI component for browsing cloud-hosted clothing and hair items, searching the package cloud, showing purchased inventory, and adding items to the local session wardrobe. It queries Package.FindAsync, loads clothing via ClothingResources.Load, and invokes a Selected callback with a WardrobeItem.
@using Sandbox
@using Sandbox.UI
@using System
@using System.Threading.Tasks
@namespace CharacterEditor
<root class="cloudwardrobe">
<div class="optionheading">Cloud clothing</div>
<div class="optiondescription">Find clothing and hair published by the community. Add an item to your session wardrobe, then wear it like any other item. Human-compatible models are required.</div>
<TextEntry Value=@Query OnTextEdited=@((string value)=>{Query=value;}) Placeholder="Search cloud clothing" />
<div class="choicegrid">
<button class="creatorbutton" disabled=@Busy onclick=@(()=>Search(false))>Search cloud</button>
<button class="creatorbutton quiet" disabled=@Busy onclick=@ShowInventory>Your purchased cosmetics</button>
</div>
<TextEntry Value=@AssetReference OnTextEdited=@((string value)=>{AssetReference=value;}) Placeholder="Clothing asset path or cloud:creator.item" />
<button class="creatorbutton quiet" disabled=@Busy onclick=@(()=>AddClothing(AssetReference.Trim()))>Add by reference</button>
@if(!string.IsNullOrEmpty(Message)){<div class="optiondescription">@Message</div>}
@if(InventoryVisible)
{
<div class="choicegrid thumbnailgrid">
@foreach(var item in Sandbox.Services.Inventory.Definitions.Where(x=>Sandbox.Services.Inventory.HasItem(x.Id) && !string.IsNullOrEmpty(x.PackageIdent) && (string.IsNullOrWhiteSpace(Query)||x.Name.Contains(Query,StringComparison.OrdinalIgnoreCase))))
{
<button class=@TileClass("cloud:"+item.PackageIdent) disabled=@Busy onclick=@(()=>AddClothing("cloud:"+item.PackageIdent))>
<div class="thumbnailframe"><div class="itemthumbnail" style=@($"background-image: url('{item.IconUrl}');")></div>@if(LoadingReference=="cloud:"+item.PackageIdent){<div class="clothingloading"><span class="material-icons loadingspinner">autorenew</span><span>Loading…</span></div>}</div><span class="itemlabel">@item.Name</span>
</button>
}
</div>
}
else
{
<div class="choicegrid thumbnailgrid">
@foreach(var package in Results)
{
<button class=@TileClass("cloud:"+package.FullIdent) disabled=@Busy onclick=@(()=>AddClothing("cloud:"+package.FullIdent))>
<div class="thumbnailframe"><div class="itemthumbnail" style=@($"background-image: url('{package.ThumbWide}');")></div>@if(LoadingReference=="cloud:"+package.FullIdent){<div class="clothingloading"><span class="material-icons loadingspinner">autorenew</span><span>Loading…</span></div>}</div><span class="itemlabel">@package.Title</span>
</button>
}
</div>
@if(Results.Count>0 && Results.Count<Total){<button class="creatorbutton quiet" disabled=@Busy onclick=@(()=>Search(true))>Load more</button>}
}
</root>
@code {
public Action<WardrobeItem> Selected { get; set; }
public CharacterAppearanceData Appearance { get; set; }
string Query="", AssetReference="", Message;
string LoadingReference;
string ActiveQuery;
bool Busy, InventoryVisible;
int Generation;
long Total;
List<Package> Results=new();
IEnumerable<string> Equipped => Appearance is null ? Enumerable.Empty<string>() : WardrobeCatalog.Paths(Appearance).Concat(new[] { Appearance.HairPath, Appearance.FacialHairPath }).Where(p=>!string.IsNullOrEmpty(p));
bool IsEquipped(string reference) => Equipped.Any(path=>string.Equals(path,reference,StringComparison.OrdinalIgnoreCase) || (ClothingResources.Get(reference) is { } item && ClothingResources.Get(path)==item));
string TileClass(string reference) => $"creatorbutton wardrobechoice {(IsEquipped(reference)?"chosen":"")} {(LoadingReference==reference?"loading":"")}";
void ShowInventory()
{
if(Busy)return;
InventoryVisible=true;
Message="Purchased items use your s&box inventory. Items without a human model cannot be worn here.";
}
async Task Search(bool more)
{
if(Busy)return;
Busy=true;
InventoryVisible=false;
var generation=++Generation;
Message="Searching…";
StateHasChanged();
try
{
if(!more){Results.Clear();ActiveQuery=Query;}
// Escape filters in user input so results remain clothing-only.
var words=string.Join(" ", (ActiveQuery??"").Split(' ',StringSplitOptions.RemoveEmptyEntries).Where(x=>!x.Contains(':')));
var result=await Package.FindAsync($"{words} type:clothing sort:newest",24,Results.Count);
if(!IsValid || generation!=Generation)return;
Results.AddRange(result.Packages);
Total=result.TotalCount;
Message=Total==0?"No clothing found.":$"{Total} cloud items. Click a preview to add it.";
}
catch(Exception e){if(IsValid)Message="Cloud search failed: "+e.Message;}
finally{Busy=false;if(IsValid)StateHasChanged();}
}
async Task AddClothing(string reference)
{
if(Busy)return;
Busy=true;LoadingReference=reference;Message="Loading clothing…";StateHasChanged();
try
{
var clothing=await ClothingResources.Load(reference);
if(!IsValid)return;
if(ClothingResources.IsBaseSkin(clothing))throw new InvalidOperationException("Choose a clothing or hair item; base skins are selected in the Skin tab.");
Selected?.Invoke(ClothingResources.Describe(reference,clothing));
StateHasChanged();
if(Appearance is not null) await CharacterVisual.Load(Appearance.DeepCopy());
if(!IsValid)return;
Message=(IsEquipped(reference)?"Equipped ":"Removed ")+clothing.Title+".";
}
catch(Exception e){if(IsValid)Message=e.Message;}
finally{Busy=false;LoadingReference=null;if(IsValid)StateHasChanged();}
}
protected override async Task OnParametersSetAsync()
{
if(ActiveQuery is null && !Busy) await Search(false);
}
protected override int BuildHash()=>HashCode.Combine(Query,Message,Busy,InventoryVisible,Results.Count,Total,HashCode.Combine(Sandbox.Services.Inventory.Items?.Count,AssetReference,LoadingReference,ClothingResources.Revision),string.Join("|",Equipped));
}