UI/SpawnMenu/SaveMenu.razor

A Razor UI panel for the Spawn Menu save tab. It lists .sav files from the local data saves folder, shows optional thumbnails, timestamps and compatibility badges, and allows creating, loading and deleting saves with confirmation popups.

File Access
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits Panel
@attribute [SpawnMenuHost.SpawnMenuMode<SpawnMenuHost.HostOnly>]
@attribute [Icon( "💾" )]
@attribute [Title( "#spawnmenu.tab.saves" )]
@attribute [Order( 150 )]

<root>
    <div class="save-window">
        <div class="save-header">💾 <label>#spawnmenu.saves.header</label></div>
        <div class="body">
            @if ( !SaveFiles.Any() )
            {
                <div class="empty-state">#spawnmenu.saves.empty</div>
            }
            @foreach ( var save in SaveFiles )
            {
                var s = save;
                <div class="save-card @(!s.IsCompatible ? "outdated" : "")"
                     @onclick="@(() => ConfirmLoad( s ))"
                     @onmousedown="@((PanelEvent e) => OnCardMouseDown( s, e ))">
                    @if ( s.Thumbnail != null )
                    {
                        <Image class="save-thumb" Texture="@s.Thumbnail" />
                    }
                    <div class="save-info @(s.Thumbnail == null ? "save-info--padded" : "")">
                        <label class="save-name">@s.DisplayName</label>
                        @if ( !string.IsNullOrEmpty( s.Timestamp ) )
                        {
                            <label class="save-timestamp">@s.Timestamp</label>
                        }
                        @if ( !s.IsCompatible )
                        {
                            <label class="save-outdated-badge">#spawnmenu.saves.outdated_badge</label>
                        }
                    </div>
                    <div class="save-dots">⋮</div>
                </div>
            }
        </div>
        <div class="footer">
            <TextEntry @ref="SaveNameEntry" Placeholder="#spawnmenu.saves.name_placeholder" @onsubmit="@OnSave"></TextEntry>
            <button @onclick="@OnSave">💾 <label>#spawnmenu.saves.save_button</label></button>
        </div>
    </div>
</root>

@code
{
    TextEntry SaveNameEntry;
    List<SaveFileEntry> SaveFiles = new();

    record SaveFileEntry( string FileName, string DisplayName, string Timestamp, Texture Thumbnail, bool IsCompatible );
    static string SavesPath => "saves";

    protected override void OnAfterTreeRender ( bool firstTime )
    {
        if ( firstTime )
        {
            RefreshSaveList();
        }
    }

    void RefreshSaveList()
    {
        SaveFiles.Clear();

        if ( !FileSystem.Data.DirectoryExists( SavesPath ) )
        {
            FileSystem.Data.CreateDirectory( SavesPath );
        }

        foreach ( var file in FileSystem.Data.FindFile( SavesPath, "*.sav" ) )
        {
            var filePath = $"{SavesPath}/{file}";
            var displayName = file;
            string timestamp = null;
            Texture thumbnail = null;

            var metadata = SaveSystem.GetFileMetadata( filePath );
            if ( metadata != null )
            {
                if ( metadata.TryGetValue( "Title", out var name ) )
                    displayName = name;
                if ( metadata.TryGetValue( "Timestamp", out var ts ) )
                    timestamp = ts;
            }

            var isCompatible = SaveSystem.GetFileSaveVersion( filePath ) == SaveSystem.SaveVersion;

            var thumbPath = $"{SavesPath}/{System.IO.Path.GetFileNameWithoutExtension( file )}.thumb.png";
            if ( FileSystem.Data.FileExists( thumbPath ) )
            {
                thumbnail = Texture.LoadFromFileSystem( thumbPath, FileSystem.Data );
            }

            SaveFiles.Add( new SaveFileEntry( file, displayName, timestamp, thumbnail, isCompatible ) );
        }

        StateHasChanged();
    }

    void OnCardMouseDown( SaveFileEntry save, PanelEvent e )
    {
        if ( e is MousePanelEvent { MouseButton: MouseButtons.Right } me )
        {
            me.StopPropagation();
            var menu = MenuPanel.Open( this );
            menu.AddOption( "download", "#spawnmenu.saves.load_context", () => ConfirmLoad( save ) );
            menu.AddOption( "delete", "#spawnmenu.saves.delete_context", () => ConfirmDelete( save ) );
        }
    }

    void ConfirmLoad( SaveFileEntry save )
    {
        if ( !save.IsCompatible )
        {
            _ = new StringQueryPopup
            {
                Title = "#spawnmenu.saves.incompatible_title",
                Prompt = Game.Language.GetPhrase( "spawnmenu.saves.incompatible_message", new() { { "name", save.DisplayName } } ),
                ConfirmLabel = "#spawnmenu.common.ok",
                ShowInput = false,
                Parent = FindPopupPanel()
            };
            return;
        }

        _ = new StringQueryPopup
        {
            Title = "#spawnmenu.saves.load_title",
            Prompt = Game.Language.GetPhrase( "spawnmenu.saves.load_confirm", new() { { "name", save.DisplayName } } ),
            ConfirmLabel = "#spawnmenu.saves.load_button",
            ShowInput = false,
            OnConfirm = _ => LoadSave( save.FileName ),
            Parent = FindPopupPanel()
        };
    }

    void ConfirmDelete( SaveFileEntry save )
    {
        var popup = new StringQueryPopup
        {
            Title = "#spawnmenu.saves.delete_title",
            Prompt = Game.Language.GetPhrase( "spawnmenu.saves.delete_confirm", new() { { "name", save.DisplayName } } ),
            ConfirmLabel = "#spawnmenu.saves.delete_button",
            ShowInput = false,
            OnConfirm = _ => DeleteSave( save.FileName ),
            Parent = FindPopupPanel()
        };
    }
    void OnSave()
    {
        var saveName = SaveNameEntry?.Text?.Trim();

        if ( string.IsNullOrEmpty( saveName ) )
            return;

        var now = DateTime.Now;
        var timeString = now.ToString( "yyyy-MM-dd HH:mm:ss" );
        var baseName = now.ToString( "yyyy-MM-dd-HH-mm-ss" );
        var fileName = $"{SavesPath}/{baseName}.sav";

        // Capture a 256x144 (16:9) thumbnail from the scene camera
        var camera = Game.ActiveScene?.Camera;
        if ( camera.IsValid() )
        {
            var bitmap = new Bitmap( 256, 144 );
            camera.RenderToBitmap( bitmap );
            FileSystem.Data.WriteAllBytes( $"{SavesPath}/{baseName}.thumb.png", bitmap.ToPng() );
        }

        SaveSystem.Current.SetMetadata( "Timestamp", timeString );
        SaveSystem.Current.SetMetadata( "Title", saveName );
        SaveSystem.Current.Save( fileName );

        SaveNameEntry.Text = "";
        RefreshSaveList();
    }

    void LoadSave( string fileName )
    {
        _ = SaveSystem.Current.Load( $"{SavesPath}/{fileName}" );
    }

    void DeleteSave( string fileName )
    {
        FileSystem.Data.DeleteFile( $"{SavesPath}/{fileName}" );
        var thumbPath = $"{SavesPath}/{System.IO.Path.GetFileNameWithoutExtension( fileName )}.thumb.png";
        if ( FileSystem.Data.FileExists( thumbPath ) )
            FileSystem.Data.DeleteFile( thumbPath );
        RefreshSaveList();
    }
}