Editor/Panels/SharePanel.cs

Editor UI panel for the Supershot tool that lets the user pick a saved screenshot, compose a Discord message, manage webhook channel configurations, and post images to Discord webhooks. It builds the sidebar, image picker, message composer, preview, and actions for testing/posting webhooks.

File AccessHttp Calls
🌐 https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
using System;
using System.Collections.Generic;
using System.IO;
using Sandbox;

namespace Editor.SuperShot;

public sealed class SharePanel : Widget
{
	readonly SuperShotWindow _window;
	readonly DiscordMessage _postMessage;

	Widget _channels;
	Label _previewMessage;
	Label _targetSummary;
	CapturedShot _selectedShot;

	public SharePanel( SuperShotWindow window ) : base( null )
	{
		_window = window;
		_postMessage = _window.Settings.Share.Message?.Clone() ?? new DiscordMessage();
		Name = "Discord";
		WindowTitle = "Discord";
		SetWindowIcon( "forum" );
		Layout = Layout.Column();
		Layout.Margin = 8;
		Layout.Spacing = 8;

		_window.Changed += Rebuild;
		Build();
	}

	void Build()
	{
		EnsureSelectedShot();

		SuperShotUI.AddBanner( Layout, "Discord", "Pick a saved shot, write a simple message, and post it to Discord.", "forum" );

		var workspace = new Widget( this );
		workspace.Layout = Layout.Row();
		workspace.Layout.Spacing = 8;
		Layout.Add( workspace, 1 );

		AddChannelSidebar( workspace.Layout );
		AddComposerPane( workspace.Layout );
	}

	void Rebuild()
	{
		if ( !IsValid )
			return;

		Layout.Clear( true );
		Build();
	}

	void AddComposerPane( Layout workspace )
	{
		var right = new ScrollArea( this );
		right.FixedWidth = 540;
		right.MinimumSize = new Vector2( 540, 0 );
		right.MaximumSize = new Vector2( 540, 100000 );
		right.Canvas = new Widget( right );
		right.Canvas.FixedWidth = 520;
		right.Canvas.Layout = Layout.Column();
		right.Canvas.Layout.Margin = 0;
		right.Canvas.Layout.Spacing = 8;
		workspace.Add( right, 0 );

		var body = right.Canvas.Layout;
		AddPreviewCard( body );
		AddImagePicker( body );
		AddMessageCard( body );
		body.AddStretchCell();
	}

	void AddChannelSidebar( Layout workspace )
	{
		var left = new ScrollArea( this );
		left.Canvas = new Widget( left );
		left.Canvas.Layout = Layout.Column();
		left.Canvas.Layout.Margin = 0;
		left.Canvas.Layout.Spacing = 8;
		workspace.Add( left, 1 );

		var body = left.Canvas.Layout;
		AddPostActions( body );
		AddChannels( body );
		body.AddStretchCell();
	}

	void AddImagePicker( Layout body )
	{
		var card = SuperShotUI.AddCard( body, "What to Post", "image" );

		var recentHeader = card.Body.AddRow();
		recentHeader.Spacing = 4;
		recentHeader.Add( new Label( string.IsNullOrEmpty( SelectedImagePath() ) ? "Pick a saved shot." : SelectedTitle() ) { Color = SuperShotUI.Muted } );
		recentHeader.AddStretchCell();
		recentHeader.Add( new Button( "Refresh", "refresh" ) { Clicked = () => _window.RefreshGalleryFromDisk() } );

		if ( _window.Gallery.Count == 0 )
		{
			card.Body.Add( new Label( "No saved shots yet. Capture from the Home tab and they will show up here." ) { Color = SuperShotUI.Muted, WordWrap = true } );
			return;
		}

		Layout row = null;
		int inRow = 0;
		int shown = 0;

		for ( int i = _window.Gallery.Count - 1; i >= 0 && shown < 6; i--, shown++ )
		{
			if ( inRow == 0 )
			{
				row = card.Body.AddRow();
				row.Spacing = 6;
			}

			var shot = _window.Gallery[i];
			row.Add( new RecentShotButton( this, shot, shot == _selectedShot ), 1 );
			inRow++;

			if ( inRow == 2 )
			{
				inRow = 0;
				row = null;
			}
		}

		if ( row is not null )
			row.AddStretchCell();

		card.Body.Add( new Button( "Open Full Gallery", "collections" ) { Clicked = () => _window.DockManager.RaiseDock( "Gallery" ) } );
	}

	void AddMessageCard( Layout body )
	{
		var card = SuperShotUI.AddCard( body, "Message", "edit_note" );
		card.Body.Add( new Label( "Write one simple message for this post. Leave it blank to send just the image." ) { Color = SuperShotUI.Muted, WordWrap = true } );

		var so = _postMessage.GetSerialized();
		so.OnPropertyChanged += _ => UpdatePreviewText();
		card.Body.Add( SuperShotUI.SheetWidget( so, p => p.Name == nameof( DiscordMessage.Content ) ) );

		var actions = card.Body.AddRow();
		actions.Spacing = 4;
		actions.Add( new Button( "Save as Default", "save" )
		{
			Clicked = () =>
			{
				_window.Settings.Share.Message = _postMessage.Clone();
				_window.Settings.Save();
				Log.Info( "[Supershot] Saved Discord message default." );
			}
		} );
		actions.Add( new Button( "Clear", "backspace" )
		{
			Clicked = () =>
			{
				_postMessage.Content = "";
				Rebuild();
			}
		} );
		actions.AddStretchCell();

		SuperShotUI.AddSection( card.Body, "Bot Identity (optional)", "smart_toy",
			SuperShotUI.SheetWidget( so, p => p.Name is nameof( DiscordMessage.Username ) or nameof( DiscordMessage.AvatarUrl ) ),
			"supershot.discord.identity", defaultOpen: false );
	}

	void AddPreviewCard( Layout body )
	{
		var card = SuperShotUI.AddCard( body, "Post Preview", "preview" );
		_previewMessage = new Label( PreviewMessageText() ) { WordWrap = true };
		card.Body.Add( _previewMessage );
		card.Body.Add( new DiscordAttachmentPreview( _selectedShot ) );

		_targetSummary = new Label( TargetSummaryText() ) { Color = SuperShotUI.Muted, WordWrap = true };
		card.Body.Add( _targetSummary );
	}

	void AddChannels( Layout body )
	{
		var card = SuperShotUI.AddCard( body, "Discord Channels", "forum" );
		card.Body.Add( new Label( "Add one webhook per Discord channel. Enable the channels that should receive Post to All." ) { Color = SuperShotUI.Muted, WordWrap = true } );

		_channels = new Widget( null );
		_channels.Layout = Layout.Column();
		_channels.Layout.Spacing = 6;
		card.Body.Add( _channels );
		RebuildChannels();

		if ( _window.Settings.Share.Webhooks.Count > 0 )
		{
			var addRow = card.Body.AddRow();
			addRow.Spacing = 4;
			addRow.Add( new Button.Primary( "Add Channel", "add" ) { Clicked = AddChannel } );
			addRow.AddStretchCell();
		}
	}

	void RebuildChannels()
	{
		_channels.Layout.Clear( true );

		var list = _window.Settings.Share.Webhooks;
		if ( list.Count == 0 )
		{
			var empty = new DiscordEmptyState( AddChannel );
			_channels.Layout.Add( empty );
			return;
		}

		foreach ( var webhook in list.ToArray() )
			_channels.Layout.Add( BuildChannelCard( webhook ) );
	}

	Widget BuildChannelCard( DiscordWebhookConfig webhook )
	{
		var card = new Card();

		var header = card.Body.AddRow();
		header.Spacing = 6;
		header.Add( new IconLabel( "tag" ) );
		var title = new Label.Subtitle( string.IsNullOrWhiteSpace( webhook.Name ) ? "Unnamed channel" : webhook.Name );
		header.Add( title );
		header.Add( new StatusPill( webhook ) );
		header.AddStretchCell();

		var star = new Button( "Favorite", webhook.Favorite ? "star" : "star_outline" )
		{
			ToolTip = webhook.Favorite ? "Unpin from the quick-post menus" : "Pin to the quick-post menus",
			Clicked = () =>
			{
				webhook.Favorite = !webhook.Favorite;
				_window.Settings.Save();
				RebuildChannels();
			}
		};
		if ( webhook.Favorite )
			star.Tint = new Color( 0.95f, 0.75f, 0.2f );
		header.Add( star );

		var so = webhook.GetSerialized();
		so.OnPropertyChanged += _ => _window.Settings.Save();
		card.Body.Add( SuperShotUI.SheetWidget( so, IsChannelBasic ) );

		var urlHeader = card.Body.AddRow();
		urlHeader.Spacing = 6;
		urlHeader.Add( new IconLabel( "link" ) );
		urlHeader.Add( new Label( "Webhook URL" ) { Color = SuperShotUI.Muted } );
		urlHeader.AddStretchCell();
		card.Body.Add( SuperShotUI.SheetWidget( so, p => p.Name == nameof( DiscordWebhookConfig.Url ) ) );

		var actions = card.Body.AddRow();
		actions.Spacing = 4;
		actions.Add( new Button( "Test", "science" )
		{
			Enabled = webhook.IsConfigured,
			ToolTip = "Send a tiny text-only test message to this channel.",
			Clicked = () => _ = TestWebhook( webhook )
		} );
		actions.Add( new Button.Primary( "Post This Image", "send" )
		{
			Enabled = CanPostSelected() && webhook.IsConfigured,
			ToolTip = "Post the selected library image to this channel.",
			Clicked = () => _ = PostSelectedTo( webhook )
		} );
		actions.AddStretchCell();
		actions.Add( new Button.Danger( "Remove", "delete" ) { Clicked = () => RemoveChannel( webhook ) } );

		return card;
	}

	static bool IsChannelBasic( SerializedProperty p )
	{
		return p.Name is nameof( DiscordWebhookConfig.Name )
			or nameof( DiscordWebhookConfig.Enabled );
	}

	void AddChannel()
	{
		_window.Settings.Share.Webhooks.Add( new DiscordWebhookConfig() );
		_window.Settings.Save();
		Rebuild();
	}

	void RemoveChannel( DiscordWebhookConfig webhook )
	{
		_window.Settings.Share.Webhooks.Remove( webhook );
		_window.Settings.Save();
		Rebuild();
	}

	void AddPostActions( Layout body )
	{
		bool canPost = CanPostSelected() && SuperShotService.EnabledWebhooks( _window.Settings.Share ).Count > 0;
		var card = SuperShotUI.AddCard( body, "Post", "send" );
		card.Body.Add( new Label( TargetSummaryText() ) { Color = SuperShotUI.Muted, WordWrap = true } );

		var row = card.Body.AddRow();
		row.Spacing = 4;
		row.Add( new Button.Primary( "Post to All Enabled", "send" ) { Enabled = canPost, Clicked = () => _ = PostSelectedToMany( SuperShotService.EnabledWebhooks( _window.Settings.Share ) ) }, 1 );
		row.Add( new Button( "Capture + Post to All", "photo_camera" ) { Enabled = SuperShotService.EnabledWebhooks( _window.Settings.Share ).Count > 0, Clicked = CaptureAndPostAll }, 1 );
	}

	void EnsureSelectedShot()
	{
		if ( _selectedShot is not null && _window.Gallery.Contains( _selectedShot ) )
			return;

		_selectedShot = FindShotByPath( _window.Settings.Share.LastSelectedImagePath );
		if ( _selectedShot is not null )
			return;

		for ( int i = _window.Gallery.Count - 1; i >= 0; i-- )
		{
			if ( !string.IsNullOrEmpty( _window.Gallery[i].SavedPath ) )
			{
				_selectedShot = _window.Gallery[i];
				return;
			}
		}
	}

	CapturedShot FindShotByPath( string path )
	{
		if ( string.IsNullOrEmpty( path ) )
			return null;

		foreach ( var shot in _window.Gallery )
		{
			if ( string.Equals( shot.SavedPath, path, StringComparison.OrdinalIgnoreCase ) )
				return shot;
		}

		return null;
	}

	void SelectShot( CapturedShot shot )
	{
		_selectedShot = shot;
		_window.Settings.Share.LastSelectedImagePath = shot?.SavedPath ?? "";
		_window.Settings.Save();
		Rebuild();
	}

	void CaptureAndPostAll()
	{
		var path = _window.Capture();
		if ( string.IsNullOrEmpty( path ) )
			return;

		SelectShot( FindShotByPath( path ) ?? (_window.Gallery.Count > 0 ? _window.Gallery[^1] : null) );
		_ = PostSelectedToMany( SuperShotService.EnabledWebhooks( _window.Settings.Share ) );
	}

	async System.Threading.Tasks.Task TestWebhook( DiscordWebhookConfig webhook )
	{
		var ok = await DiscordWebhook.PostText( webhook.Url, "Supershot test message. This Discord channel is connected." );
		webhook.LastPostFailed = !ok;
		webhook.LastError = ok ? "" : "Test failed";
		_window.Settings.Save();
		Rebuild();
	}

	async System.Threading.Tasks.Task PostSelectedTo( DiscordWebhookConfig webhook )
	{
		await SuperShotService.PostFileTo( webhook, SelectedImagePath(), _postMessage );
		Rebuild();
	}

	async System.Threading.Tasks.Task PostSelectedToMany( IEnumerable<DiscordWebhookConfig> webhooks )
	{
		var list = new List<DiscordWebhookConfig>();
		foreach ( var wh in webhooks )
		{
			if ( wh is not null && wh.Enabled && wh.IsConfigured )
				list.Add( wh );
		}

		int sent = await SuperShotService.PostFileToMany( list, SelectedImagePath(), _postMessage );
		Log.Info( $"[Supershot] Posted selected image to {sent}/{list.Count} Discord channel(s)." );
		Rebuild();
	}

	string SelectedImagePath() => _selectedShot?.SavedPath;

	bool CanPostSelected()
	{
		var path = SelectedImagePath();
		return !string.IsNullOrEmpty( path ) && File.Exists( path );
	}

	string SelectedTitle()
	{
		var path = SelectedImagePath();
		if ( string.IsNullOrEmpty( path ) )
			return "No saved image selected";

		return Path.GetFileName( path );
	}

	string SelectedDetails()
	{
		if ( _selectedShot is null )
			return "Choose a recent shot from your library.";

		var saved = string.IsNullOrEmpty( _selectedShot.SavedPath ) ? "Not saved yet" : _selectedShot.SavedPath;
		return $"{_selectedShot.Size.x}x{_selectedShot.Size.y} · {_selectedShot.Time:g}\n{saved}";
	}

	string PreviewMessageText()
	{
		if ( string.IsNullOrWhiteSpace( _postMessage.Content ) )
			return "";

		return _postMessage.Content;
	}

	string TargetSummaryText()
	{
		int enabled = SuperShotService.EnabledWebhooks( _window.Settings.Share ).Count;
		var image = CanPostSelected() ? Path.GetFileName( SelectedImagePath() ) : "no image selected";
		return $"Posting {image} to {enabled} enabled channel(s).";
	}

	void UpdatePreviewText()
	{
		if ( _previewMessage is not null )
			_previewMessage.Text = PreviewMessageText();
		if ( _targetSummary is not null )
			_targetSummary.Text = TargetSummaryText();
	}

	public override void OnDestroyed()
	{
		base.OnDestroyed();
		_window.Changed -= Rebuild;
	}

	sealed class DiscordEmptyState : Widget
	{
		readonly Action _addChannel;

		public DiscordEmptyState( Action addChannel ) : base( null )
		{
			_addChannel = addChannel;
			MinimumSize = new Vector2( 0, 190 );
			Layout = Layout.Column();
			Layout.Margin = 18;
			Layout.Spacing = 10;

			Layout.Add( new Label.Subtitle( "No Discord channels yet" ) );
			Layout.Add( new Label( "Create a Discord webhook in your server channel settings, then add it here. Supershot will save the shot locally and post it to the channels you enable." )
			{
				WordWrap = true,
				Color = SuperShotUI.Muted
			} );

			var row = Layout.AddRow();
			row.Spacing = 8;
			row.Add( new Button.Primary( "Add Discord Channel", "add" )
			{
				FixedHeight = Theme.RowHeight * 2,
				Clicked = _addChannel
			} );
			row.Add( new Button( "How to Get a Webhook", "help" )
			{
				FixedHeight = Theme.RowHeight * 2,
				Clicked = () =>
				{
					EditorUtility.Clipboard.Copy( "https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks" );
					Log.Info( "[Supershot] Copied Discord webhook setup guide URL." );
				}
			} );
			row.AddStretchCell();
		}

		protected override void OnPaint()
		{
			Paint.ClearPen();
			Paint.SetBrush( SuperShotUI.AccentDim.WithAlpha( 0.22f ) );
			Paint.DrawRect( LocalRect, 8f );

			Paint.ClearBrush();
			Paint.SetPen( SuperShotUI.Accent.WithAlpha( 0.65f ), 1f );
			Paint.DrawRect( LocalRect.Shrink( 0.5f ), 8f );

			Paint.SetPen( SuperShotUI.Accent.WithAlpha( 0.9f ) );
			Paint.DrawIcon( new Rect( Width - 68, 18, 44, 44 ), "forum", 34, TextFlag.Center );
		}
	}

	sealed class SelectedShotPreview : Widget
	{
		readonly CapturedShot _shot;

		public SelectedShotPreview( CapturedShot shot ) : base( null )
		{
			_shot = shot;
			MinimumSize = new Vector2( 260, 170 );
		}

		protected override void OnPaint()
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.ControlBackground.Darken( 0.25f ) );
			Paint.DrawRect( LocalRect, 6f );

			if ( _shot?.Thumbnail is not null )
			{
				Paint.Draw( FitRect( _shot.Thumbnail.Size, LocalRect.Shrink( 10 ) ), _shot.Thumbnail );
				return;
			}

			Paint.SetPen( SuperShotUI.Muted );
			Paint.DrawText( LocalRect, "No Image Selected", TextFlag.Center );
		}
	}

	sealed class DiscordAttachmentPreview : Widget
	{
		readonly CapturedShot _shot;

		public DiscordAttachmentPreview( CapturedShot shot ) : base( null )
		{
			_shot = shot;
			MinimumSize = new Vector2( 0, 260 );
		}

		protected override void OnPaint()
		{
			Paint.ClearPen();
			Paint.SetBrush( new Color( 0.09f, 0.10f, 0.12f ) );
			Paint.DrawRect( LocalRect, 7f );

			if ( _shot?.Thumbnail is not null )
			{
				var imageRect = FitRect( _shot.Thumbnail.Size, LocalRect.Shrink( 6 ) );
				Paint.Draw( imageRect, _shot.Thumbnail );
			}
			else
			{
				Paint.SetPen( SuperShotUI.Muted );
				Paint.DrawText( LocalRect, "No Image Selected", TextFlag.Center );
			}
		}
	}

	sealed class RecentShotButton : Widget
	{
		readonly SharePanel _panel;
		readonly CapturedShot _shot;
		readonly bool _selected;

		public RecentShotButton( SharePanel panel, CapturedShot shot, bool selected ) : base( null )
		{
			_panel = panel;
			_shot = shot;
			_selected = selected;
			MinimumSize = new Vector2( 0, 112 );
			Cursor = CursorShape.Finger;
			ToolTip = $"{shot.Size.x}x{shot.Size.y}  {shot.Time:g}";
		}

		protected override void OnMouseClick( MouseEvent e )
		{
			base.OnMouseClick( e );
			if ( e.LeftMouseButton )
				_panel.SelectShot( _shot );
		}

		protected override void OnPaint()
		{
			var bg = _selected
				? SuperShotUI.AccentDim.WithAlpha( 0.7f )
				: IsUnderMouse ? SuperShotUI.CardBorder.WithAlpha( 0.65f ) : SuperShotUI.CardBackground;

			Paint.ClearPen();
			Paint.SetBrush( bg );
			Paint.DrawRect( LocalRect, 6f );

			Paint.ClearBrush();
			Paint.SetPen( _selected ? SuperShotUI.Accent : IsUnderMouse ? SuperShotUI.Accent : SuperShotUI.CardBorder, _selected ? 2f : 1f );
			Paint.DrawRect( LocalRect.Shrink( 0.5f ), 6f );

			var imageRect = new Rect( 6, 6, Width - 12, Height - 30 );
			Paint.ClearPen();
			Paint.SetBrush( Color.Black );
			Paint.DrawRect( imageRect, 4f );

			if ( _shot.Thumbnail is not null )
				Paint.Draw( FitRect( _shot.Thumbnail.Size, imageRect ), _shot.Thumbnail );
			else
			{
				Paint.SetPen( SuperShotUI.Muted );
				Paint.DrawIcon( imageRect, "image", 22, TextFlag.Center );
			}

			Paint.SetDefaultFont( 7, 500 );
			Paint.SetPen( Theme.Text.WithAlpha( 0.9f ) );
			var label = _shot.SavedPath is not null ? "Saved shot" : "Recent shot";
			Paint.DrawText( new Rect( 8, Height - 22, Width - 16, 10 ), label, TextFlag.LeftTop );

			Paint.SetPen( SuperShotUI.Muted );
			Paint.DrawText( new Rect( 8, Height - 12, Width - 16, 10 ), $"{_shot.Size.x}x{_shot.Size.y}", TextFlag.LeftTop );
		}
	}

	sealed class StatusPill : Widget
	{
		readonly DiscordWebhookConfig _webhook;

		public StatusPill( DiscordWebhookConfig webhook ) : base( null )
		{
			_webhook = webhook;
			FixedSize = new Vector2( 112, 22 );
		}

		protected override void OnPaint()
		{
			var (text, color) = Status();
			Paint.ClearPen();
			Paint.SetBrush( color.WithAlpha( 0.22f ) );
			Paint.DrawRect( LocalRect, 11f );
			Paint.SetPen( color.WithAlpha( 0.9f ) );
			Paint.DrawText( LocalRect, text, TextFlag.Center );
		}

		(string, Color) Status()
		{
			if ( _webhook.LastPostFailed )
				return (string.IsNullOrWhiteSpace( _webhook.LastError ) ? "Failed" : _webhook.LastError, Color.Red);

			if ( !_webhook.IsConfigured )
				return ("No URL", Color.Orange);

			if ( _webhook.LastPostedUtc is not null )
				return ($"Posted {Ago( _webhook.LastPostedUtc.Value )}", Color.Green);

			return ("Ready", SuperShotUI.Accent);
		}

		static string Ago( DateTime utc )
		{
			var span = DateTime.UtcNow - utc;
			if ( span.TotalMinutes < 1 )
				return "now";
			if ( span.TotalHours < 1 )
				return $"{(int)span.TotalMinutes}m ago";
			if ( span.TotalDays < 1 )
				return $"{(int)span.TotalHours}h ago";
			return $"{(int)span.TotalDays}d ago";
		}
	}

	static Rect FitRect( Vector2 content, Rect area )
	{
		if ( content.x <= 0 || content.y <= 0 || area.Width <= 0 || area.Height <= 0 )
			return area;

		float sx = area.Width / content.x;
		float sy = area.Height / content.y;
		float scale = sx < sy ? sx : sy;
		var size = content * scale;
		return new Rect( area.Left + (area.Width - size.x) * 0.5f, area.Top + (area.Height - size.y) * 0.5f, size.x, size.y );
	}
}