CharacterKit/CloudClothingBrowser.razor

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.

NetworkingFile Access
@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));
}