UI/Components/CloudSpawnList.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>
<Button [email protected]() Tooltip="Icon size" class="icon-button" @onclick=@OpenIconSizeMenu />
<Button Icon="verified" Active=@VerifiedOnly Tooltip="Show verified items only" class="icon-button" @onclick=@ToggleVerified />
<Button [email protected]() [email protected]() Tooltip="Sort order" class="menu-button" @onclick=@OpenSortMenu />
</Right>
</SpawnMenuToolbar>
</Header>
<Body>
@if ( Entries.Count == 0 && !Loading )
{
if ( string.IsNullOrEmpty( EmptyTitle ) )
{
<SpawnMenuEmptyState Icon="search_off" Subtitle="#spawnmenu.empty.try_another_category" />
}
else
{
<SpawnMenuEmptyState Icon="search_off" Title=@EmptyTitle Subtitle="#spawnmenu.empty.try_another_category" />
}
}
else
{
<MixedVirtualGrid @ref=Grid Items=@Entries [email protected]() [email protected]()>
<Item Context="item">
@if ( item is Entry entry )
{
<SpawnMenuIcon [email protected] [email protected] [email protected] HideText=@(IconSize == SpawnMenuIconSize.Small) Tooltip=@(IconSize == SpawnMenuIconSize.Small ? entry.Title : null) />
}
else if ( item is CommunitySection )
{
<div class="community-section">
<div class="line left"></div>
<IconPanel Text="groups" />
<label>#spawnmenu.section.community_items</label>
<div class="line right"></div>
</div>
}
</Item>
</MixedVirtualGrid>
}
</Body>
</SpawnMenuContent>
@code
{
/// <summary>
/// A single tile in the grid, whether it came from disk or from the cloud.
/// </summary>
public record Entry( string Ident, string Title, bool Developer = false, bool Verified = true );
sealed record CommunitySection : IMixedVirtualGridFullRow
{
public bool IsFullRow => true;
public float Height => 48f;
}
protected string Filter
{
get;
set { field = value; Rebuild(); }
}
public PackageSortMode SortOrder
{
get;
set { field = value; Rebuild(); }
}
protected override void OnParametersSet()
{
SortOrder = PackageSortMode.Popular;
Rebuild();
}
List<object> Entries = [];
bool Loading;
int _rebuildVersion;
const string VerifiedOnlyCookie = "spawnmenu.verified_only";
bool VerifiedOnly => Game.Cookies.Get( VerifiedOnlyCookie, false );
bool _lastVerifiedOnly;
void ToggleVerified()
{
Game.Cookies.Set( VerifiedOnlyCookie, !VerifiedOnly );
_lastVerifiedOnly = VerifiedOnly;
Rebuild();
}
SpawnMenuIconSize IconSize
{
get => SpawnMenuIconSizeExtensions.Current;
set
{
if ( value == IconSize ) return;
SpawnMenuIconSizeExtensions.Current = value;
OnIconSizeChanged();
}
}
SpawnMenuIconSize _lastIconSize = SpawnMenuIconSizeExtensions.Current;
MixedVirtualGrid Grid;
void OnIconSizeChanged()
{
_lastIconSize = IconSize;
// The grid only recreates cells when their data changes, so throw the
// existing tiles away to pick up the new size's text and tooltip. The
// render won't re-set Items for us as the list reference is unchanged.
if ( Grid.IsValid() )
{
Grid.Clear();
Grid.Items = Entries;
}
StateHasChanged();
}
void OpenSortMenu()
{
var menu = new Sandbox.UI.Menu();
foreach ( var mode in Enum.GetValues<PackageSortMode>() )
{
var m = mode;
menu.AddOption( m.ToString(), m.Icon(), () => SortOrder = m );
}
menu.Open( this, Popup.PositionMode.UnderMouse );
}
void OpenIconSizeMenu()
{
var menu = new Sandbox.UI.Menu();
foreach ( var size in Enum.GetValues<SpawnMenuIconSize>() )
{
var s = size;
menu.AddOption( s.ToString(), s.Icon(), () => IconSize = s );
}
menu.Open( this, Popup.PositionMode.UnderMouse );
}
public override void Tick()
{
base.Tick();
// Another list may have changed these while we weren't visible
if ( _lastIconSize != IconSize )
OnIconSizeChanged();
if ( _lastVerifiedOnly == VerifiedOnly ) return;
_lastVerifiedOnly = VerifiedOnly;
Rebuild();
}
async void Rebuild()
{
var rebuildVersion = ++_rebuildVersion;
var popular = SortOrder == PackageSortMode.Popular;
var localEntries = FindLocalEntries().Cast<object>().ToList();
var entries = popular ? localEntries.ToList() : new List<object>();
Entries = entries;
Loading = true;
StateHasChanged();
var query = $"sort:{SortOrder.ToIdentifier()} type:{PackageType}";
if ( !string.IsNullOrEmpty( Filter ) ) query += $" {Filter}";
if ( !string.IsNullOrEmpty( PackageQuery ) ) query += $" {PackageQuery}";
var result = await Package.FindAsync( query );
if ( rebuildVersion != _rebuildVersion ) return;
var packages = result.Packages.AsEnumerable();
if ( VerifiedOnly )
packages = packages.Where( IsVerified );
var packageList = packages.ToList();
if ( popular )
{
entries.AddRange( packageList.Where( IsVerified ).Select( CreateEntry ) );
var communityPackages = packageList.Where( package => !IsVerified( package ) ).ToList();
if ( communityPackages.Count > 0 && entries.Count > 0 && !VerifiedOnly )
entries.Add( new CommunitySection() );
entries.AddRange( communityPackages.Select( CreateEntry ) );
}
else
{
// Keep the requested workshop order; local items have no ranking metadata.
entries.AddRange( packageList.Select( CreateEntry ) );
entries.AddRange( localEntries );
}
Entries = entries;
Loading = false;
StateHasChanged();
}
static bool IsVerified( Package package ) => string.Equals( package.Org?.Ident, "facepunch", StringComparison.OrdinalIgnoreCase );
Entry CreateEntry( Package package ) => new( PackageIdent( package ), package.Title, false, IsVerified( package ) );
/// <summary>
/// Value of the "type:" term in the package search, e.g. "sent" or "model".
/// </summary>
protected virtual string PackageType => "";
/// <summary>
/// Extra terms appended to the package search.
/// </summary>
protected virtual string PackageQuery => null;
/// <summary>
/// Title shown when nothing matched. Null uses the empty state's default.
/// </summary>
protected virtual string EmptyTitle => null;
/// <summary>
/// Build the spawn ident for a cloud package.
/// </summary>
protected virtual string PackageIdent( Package package ) => $"{package.TypeName}:{package.FullIdent}";
/// <summary>
/// Local entries shown ahead of cloud results in Popular mode, otherwise after them.
/// Should respect <see cref="Filter"/>.
/// </summary>
protected virtual IEnumerable<Entry> FindLocalEntries() => [];
}