UI/CommunityBrowser.razor

UI Razor component for the community games browser. It shows a searchable, sortable grid of community game packages, queries Package.FindAsync, and asks GameDirector to mount/start a selected package.

NetworkingExternal Download
@namespace CardGames
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@using System.Collections.Generic
@inherits Panel

@*
	The full games browser - reached from the launcher's "Browse Games" button (host only).
	A search box and sort tabs drive a Package.FindAsync query; results show as cards. Picking one asks the
	director to mount its cloud package and start it. Failures (won't download, not a card game) stay here.
*@

<root class="cgui">
	<button class="back" onclick=@(() => Director?.CloseBrowser())>‹ Back</button>

	<div class="hall">
		<div class="controls">
			<div class="search">
				<span class="ico">search</span>
				<TextEntry placeholder="Search games…" OnTextEdited=@OnSearchEdited></TextEntry>
			</div>
			<div class="sorts">
				@foreach ( var s in Sorts )
				{
					var sort = s;
					<button class="sort @(_sort == sort.Key ? "active" : "")" onclick=@(() => SetSort( sort.Key ))>@sort.Value</button>
				}
			</div>
		</div>

		@if ( !string.IsNullOrEmpty( _error ) )
		{
			<div class="cg-error">@_error</div>
		}

		@if ( _busy )
		{
			<div class="cg-note">Searching…</div>
		}
		else if ( _results.Count == 0 )
		{
			<div class="cg-note">No games found. Try a different search.</div>
		}
		else
		{
			<div class="grid">
				@foreach ( var package in _results )
				{
					var p = package;
					<GameCard Package=@p OnClick=@(() => Select( p )) Loading=@(_loadingIdent == p.FullIdent) />
				}
			</div>
		}
	</div>
</root>

@code
{
	private GameDirector _director;
	private GameDirector Director => _director ??= Sandbox.Game.ActiveScene?.Get<GameDirector>();

	// Sort orders offered as tabs - the key goes straight into the FindAsync query (sort:<key>).
	private static readonly KeyValuePair<string, string>[] Sorts =
	{
		new( "trending", "Trending" ),
		new( "popular", "Popular" ),
		new( "newest", "Newest" ),
		new( "quality", "Top Rated" ),
	};

	private string _sort = "popular";
	private string _error = "";
	private bool _busy;
	private string _loadingIdent;                 // the package currently being mounted/started (per-card spinner)
	private List<Package> _results = new();

	private string _query = "";
	private bool _searchPending;
	private RealTimeSince _sinceEdited;
	private bool _searched;                        // kick off the first search once, after the panel mounts

	private void SetSort( string sort )
	{
		if ( _sort == sort ) return;
		_sort = sort;
		Refresh();
	}

	// Debounce typing: note the edit, then fire the search a beat after the last keystroke (see Tick).
	private void OnSearchEdited( string text )
	{
		_query = text ?? "";
		_searchPending = true;
		_sinceEdited = 0f;
	}

	public override void Tick()
	{
		base.Tick();

		if ( !_searched )
		{
			_searched = true;
			Refresh();
		}

		if ( _searchPending && _sinceEdited > 0.3f )
		{
			_searchPending = false;
			Refresh();
		}
	}

	// Query the cloud for games matching the current sort + search text.
	private async void Refresh()
	{
		_busy = true;
		_error = "";
		StateHasChanged();

		try
		{
			var query = $"type:cggame sort:{_sort} {_query}".Trim();
			var result = await Package.FindAsync( query, 60 );
			_results = result.Packages?.ToList() ?? new();
		}
		catch ( Exception e )
		{
			_results = new();
			_error = "Couldn't reach the games list. Check your connection.";
			Log.Warning( e, "Games search failed" );
		}

		_busy = false;
		StateHasChanged();
	}

	// Mount + start the chosen package. On success the HUD swaps this panel out; on failure we show why.
	private async void Select( Package package )
	{
		if ( _loadingIdent is not null || Director is null ) return;

		_loadingIdent = package.FullIdent;
		_error = "";
		StateHasChanged();

		var error = await Director.StartCommunityGame( package );

		_loadingIdent = null;
		if ( !string.IsNullOrEmpty( error ) )
			_error = error;
		StateHasChanged();
	}

	protected override int BuildHash() => HashCode.Combine( _busy, _error, _loadingIdent, _sort, _results.Count );
}