UI/SpawnMenu/Spawnlists/SpawnlistWorkshop.razor
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
<SpawnMenuContent>
<Header>
<SpawnMenuToolbar>
<Left>
<TextEntry Placeholder="#spawnmenu.common.search" class="filter menu-input" Value:bind=@Filter />
</Left>
<Right>
<DropDown Value:bind=@SortOrder />
</Right>
</SpawnMenuToolbar>
</Header>
<Body>
@if ( Items.Count == 0 && !Loading )
{
<SpawnMenuEmptyState Icon="search_off" Subtitle="#spawnmenu.empty.try_another_category" />
}
else
{
<VirtualGrid Items=@Items ItemSize=@(new Vector2( 340, 170 )) OnLastCell="@(() => { _ = QueryNext(); })">
<Item Context="context">
@if ( context is WorkshopSpawnlist entry )
{
<div class="workshop-spawnlist" @onclick="@(() => _ = OnInstall( entry.Item ))">
<div class="content-preview">
@if ( entry.PreviewItems.Count > 0 )
{
var visibleCount = Math.Min( entry.PreviewItems.Count, 3 );
var hiddenCount = Math.Max( entry.ItemCount, entry.PreviewItems.Count ) - visibleCount;
<div class="preview-collage">
@for ( var index = 0; index < visibleCount; index++ )
{
var preview = entry.PreviewItems[index];
<div class="preview-item" style="@GetPreviewStyle( preview )">
<label class="preview-title">@preview.Title</label>
@if ( index == visibleCount - 1 && hiddenCount > 0 )
{
<div class="more-count">+@hiddenCount</div>
}
</div>
}
</div>
}
else
{
<div class="workshop-preview" style="@GetWorkshopPreviewStyle( entry.Item )">
@if ( string.IsNullOrWhiteSpace( entry.Item.Preview ) )
{
<div class="placeholder-art">
<div class="placeholder-sheet back"></div>
<div class="placeholder-sheet middle"></div>
<div class="placeholder-sheet front"><i>collections_bookmark</i></div>
</div>
<label>#spawnmenu.spawnlist.preview_unavailable</label>
}
</div>
}
</div>
<div class="card-details">
<div class="title-row">
<div class="name">@entry.Item.Title</div>
@if ( entry.ItemCount > 0 )
{
<div class="item-count"><i>widgets</i> @entry.ItemCount</div>
}
</div>
@if ( !string.IsNullOrWhiteSpace( entry.TypeSummary ) )
{
<div class="type-summary">@entry.TypeSummary</div>
}
@if ( !string.IsNullOrWhiteSpace( entry.Item.Description ) )
{
<div class="description">@entry.Item.Description</div>
}
<div class="card-footer">
<div class="author">
<div class="avatar" style="background-image: url('@entry.Item.Owner.Avatar');"></div>
<div class="author-name">@entry.Item.Owner.Name</div>
</div>
<div class="workshop-stats">
@if ( entry.Item.VotesUp > 0 )
{
<span><i>thumb_up</i> @entry.Item.VotesUp</span>
}
<span Tooltip="@entry.Item.Updated.ToString()"><i>schedule</i> @entry.Item.Updated.Humanize()</span>
</div>
<div class="install-button"><i>download</i> <label>#spawnmenu.spawnlist.install</label></div>
</div>
</div>
</div>
}
</Item>
</VirtualGrid>
}
</Body>
</SpawnMenuContent>
@code
{
sealed record WorkshopSpawnlist(
Storage.QueryItem Item,
int ItemCount,
List<SpawnlistItem> PreviewItems,
string TypeSummary
);
string _filter;
public string Filter
{
get => _filter;
set
{
if ( _filter == value ) return;
_filter = value;
Rebuild();
}
}
WorkshopSortMode _sortOrder = WorkshopSortMode.Popular;
public WorkshopSortMode SortOrder
{
get => _sortOrder;
set
{
if ( _sortOrder == value ) return;
_sortOrder = value;
Rebuild();
}
}
protected override async Task OnParametersSetAsync()
{
Items.Clear();
LastResult = null;
await QueryNext();
}
List<WorkshopSpawnlist> Items = new();
Storage.QueryResult LastResult;
bool Loading;
async Task QueryNext()
{
Loading = true;
if ( LastResult != null )
{
if ( !LastResult.HasMoreResults() )
{
Loading = false;
return;
}
LastResult = await LastResult.GetNextResults();
AddItems( LastResult?.Items );
Loading = false;
StateHasChanged();
return;
}
var query = new Storage.Query();
query.KeyValues["package"] = "facepunch.sandbox";
query.KeyValues["type"] = "spawnlist";
query.SortOrder = SortOrder.ToSortOrder();
if ( !string.IsNullOrWhiteSpace( Filter ) )
query.SearchText = Filter;
LastResult = await query.Run();
AddItems( LastResult?.Items );
Loading = false;
StateHasChanged();
}
void AddItems( IEnumerable<Storage.QueryItem> queryItems )
{
if ( queryItems is null ) return;
Items.AddRange( queryItems.Select( CreateViewModel ) );
}
static WorkshopSpawnlist CreateViewModel( Storage.QueryItem item )
{
var previewItems = ReadMetaJson<List<SpawnlistItem>>( item, "preview_items" ) ?? [];
var contentTypes = ReadMetaJson<Dictionary<string, int>>( item, "content_types" ) ?? [];
var itemCount = ReadMetaInt( item, "item_count" );
return new WorkshopSpawnlist(
item,
itemCount,
previewItems.Take( 6 ).ToList(),
FormatTypeSummary( contentTypes )
);
}
static string ReadMeta( Storage.QueryItem item, string key )
{
try
{
var raw = Json.ParseToJsonObject( item.Metadata )?["Meta"]?[key]?.ToString();
if ( string.IsNullOrWhiteSpace( raw ) ) return null;
// Storage metadata has existed in both native JSON-value and
// JSON-encoded string forms. Normalize either shape here.
if ( raw.Length >= 2 && raw[0] == '"' && raw[^1] == '"' )
{
try
{
return Json.Deserialize<string>( raw );
}
catch
{
return raw.Trim( '"' );
}
}
return raw;
}
catch
{
return null;
}
}
static T ReadMetaJson<T>( Storage.QueryItem item, string key )
{
try
{
var value = ReadMeta( item, key );
return string.IsNullOrWhiteSpace( value ) ? default : Json.Deserialize<T>( value );
}
catch
{
return default;
}
}
static int ReadMetaInt( Storage.QueryItem item, string key )
{
return int.TryParse( ReadMeta( item, key ), out var value ) ? value : 0;
}
static string FormatTypeSummary( Dictionary<string, int> types )
{
if ( types is null || types.Count == 0 ) return null;
return string.Join( " · ", types
.OrderByDescending( pair => pair.Value )
.Take( 4 )
.Select( pair => $"{pair.Value} {FriendlyTypeName( pair.Key, pair.Value )}" ) );
}
static string FriendlyTypeName( string type, int count )
{
var plural = count != 1;
return type?.ToLowerInvariant() switch
{
"prop" => plural ? "props" : "prop",
"dupe" => plural ? "dupes" : "dupe",
"entity" => plural ? "entities" : "entity",
"npc" => plural ? "NPCs" : "NPC",
"weapon" => plural ? "weapons" : "weapon",
_ => plural ? $"{type}s" : type
};
}
static string GetPreviewStyle( SpawnlistItem item )
{
var icon = item.Icon;
if ( string.IsNullOrWhiteSpace( icon ) )
{
var (_, path, _) = SpawnlistItem.ParseIdent( item.Ident );
if ( !string.IsNullOrWhiteSpace( path ) ) icon = $"thumb:{path}";
}
return string.IsNullOrWhiteSpace( icon ) ? null : $"background-image: url('{icon}');";
}
static string GetWorkshopPreviewStyle( Storage.QueryItem item )
{
return string.IsNullOrWhiteSpace( item.Preview ) ? null : $"background-image: url('{item.Preview}');";
}
async void Rebuild()
{
Items.Clear();
LastResult = null;
await QueryNext();
}
async Task OnInstall( Storage.QueryItem item )
{
var page = Ancestors.OfType<SpawnlistsPage>().FirstOrDefault();
if ( page is null ) return;
await page.Collection.InstallAsync( item );
}
}