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() => [];
}