diff --git a/RadzenBlazorDemos.Host/Controllers/PlaygroundController.cs b/RadzenBlazorDemos.Host/Controllers/PlaygroundController.cs new file mode 100644 index 00000000..011fec96 --- /dev/null +++ b/RadzenBlazorDemos.Host/Controllers/PlaygroundController.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc; +using RadzenBlazorDemos.Host.Services; +using System.Threading.Tasks; + +namespace RadzenBlazorDemos.Host.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class PlaygroundController(PlaygroundService playgroundService) : ControllerBase + { + [HttpGet("{id}")] + public async Task GetSnippet(string id) + { + if (!playgroundService.IsConfigured) + { + return StatusCode(503, new { error = "Playground storage is not configured." }); + } + + var snippet = await playgroundService.GetSnippetAsync(id); + + if (snippet == null) + { + return NotFound(new { error = "Snippet not found." }); + } + + return Ok(new + { + id = snippet.Id, + source = snippet.Source, + parentId = snippet.ParentId, + createdAt = snippet.CreatedAt, + updatedAt = snippet.UpdatedAt + }); + } + + [HttpPost("save")] + [ValidateAntiForgeryToken] + public async Task SaveSnippet([FromBody] SaveSnippetRequest request) + { + if (!playgroundService.IsConfigured) + { + return StatusCode(503, new { error = "Playground storage is not configured." }); + } + + if (string.IsNullOrWhiteSpace(request.Source)) + { + return BadRequest(new { error = "Source code is required." }); + } + + var result = await playgroundService.SaveSnippetAsync(request); + + return Ok(result); + } + } +} diff --git a/RadzenBlazorDemos.Host/Program.cs b/RadzenBlazorDemos.Host/Program.cs index 3debc942..f4f84e5c 100644 --- a/RadzenBlazorDemos.Host/Program.cs +++ b/RadzenBlazorDemos.Host/Program.cs @@ -15,6 +15,7 @@ using Radzen; using RadzenBlazorDemos; using RadzenBlazorDemos.Data; using RadzenBlazorDemos.Services; +using RadzenBlazorDemos.Host.Services; using Microsoft.AspNetCore.Http; var builder = WebApplication.CreateBuilder(args); @@ -54,6 +55,14 @@ builder.Services.AddSingleton(); builder.Services.AddAIChatService(options => builder.Configuration.GetSection("AIChatService").Bind(options)); +builder.Services.Configure(builder.Configuration.GetSection("Playground")); +builder.Services.AddSingleton(); + +builder.Services.AddAntiforgery(options => +{ + options.HeaderName = "X-CSRF-TOKEN"; +}); + builder.Services.AddLocalization(); /* --> Uncomment to enable localization diff --git a/RadzenBlazorDemos.Host/RadzenBlazorDemos.Host.csproj b/RadzenBlazorDemos.Host/RadzenBlazorDemos.Host.csproj index 2d5d03f7..1f55f2ee 100644 --- a/RadzenBlazorDemos.Host/RadzenBlazorDemos.Host.csproj +++ b/RadzenBlazorDemos.Host/RadzenBlazorDemos.Host.csproj @@ -11,6 +11,7 @@ + diff --git a/RadzenBlazorDemos.Host/Services/PlaygroundService.cs b/RadzenBlazorDemos.Host/Services/PlaygroundService.cs new file mode 100644 index 00000000..9b266857 --- /dev/null +++ b/RadzenBlazorDemos.Host/Services/PlaygroundService.cs @@ -0,0 +1,220 @@ +#nullable enable +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Amazon.S3; +using Amazon.S3.Model; +using Microsoft.Extensions.Options; + +namespace RadzenBlazorDemos.Host.Services +{ + public class PlaygroundOptions + { + public S3Options S3 { get; set; } = new(); + } + + public class S3Options + { + public string ServiceUrl { get; set; } = ""; + public string BucketName { get; set; } = "playground-snippets"; + public string AccessKey { get; set; } = ""; + public string SecretKey { get; set; } = ""; + } + + public class Snippet + { + public string Id { get; set; } = ""; + public string Source { get; set; } = ""; + public string EditTokenHash { get; set; } = ""; + public string? ParentId { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } + + public class SaveSnippetRequest + { + public string? Id { get; set; } + public string Source { get; set; } = ""; + public string? EditToken { get; set; } + } + + public class SaveSnippetResponse + { + public string Id { get; set; } = ""; + public string EditToken { get; set; } = ""; + public bool IsNew { get; set; } + } + + public class PlaygroundService + { + private readonly IAmazonS3 _s3Client; + private readonly string _bucketName; + private readonly bool _isConfigured; + + public PlaygroundService(IOptions options) + { + var s3Options = options.Value.S3; + _bucketName = s3Options.BucketName; + _isConfigured = !string.IsNullOrEmpty(s3Options.ServiceUrl) + && !string.IsNullOrEmpty(s3Options.AccessKey) + && !string.IsNullOrEmpty(s3Options.SecretKey); + + if (_isConfigured) + { + var config = new AmazonS3Config + { + ServiceURL = s3Options.ServiceUrl, + ForcePathStyle = true + }; + _s3Client = new AmazonS3Client(s3Options.AccessKey, s3Options.SecretKey, config); + } + else + { + // Create a dummy client that won't be used + _s3Client = null!; + } + } + + public bool IsConfigured => _isConfigured; + + public async Task GetSnippetAsync(string id) + { + if (!_isConfigured) return null; + + try + { + var response = await _s3Client.GetObjectAsync(_bucketName, $"snippets/{id}.json"); + using var reader = new StreamReader(response.ResponseStream); + var json = await reader.ReadToEndAsync(); + return JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + } + + public async Task SaveSnippetAsync(SaveSnippetRequest request) + { + if (!_isConfigured) + { + throw new InvalidOperationException("Playground S3 storage is not configured."); + } + + var now = DateTime.UtcNow; + + // Case 1: New snippet (no id provided) + if (string.IsNullOrEmpty(request.Id)) + { + return await CreateNewSnippetAsync(request.Source, null, now); + } + + // Case 2: Existing snippet - check edit token + var existingSnippet = await GetSnippetAsync(request.Id); + + if (existingSnippet == null) + { + // Snippet doesn't exist, create new one + return await CreateNewSnippetAsync(request.Source, null, now); + } + + // Verify edit token + if (!string.IsNullOrEmpty(request.EditToken) && + VerifyEditToken(request.EditToken, existingSnippet.EditTokenHash)) + { + // Valid token - update in place + return await UpdateSnippetAsync(existingSnippet, request.Source, request.EditToken, now); + } + + // Invalid or missing token - clone the snippet + return await CreateNewSnippetAsync(request.Source, request.Id, now); + } + + private async Task CreateNewSnippetAsync(string source, string? parentId, DateTime now) + { + var id = Guid.NewGuid().ToString(); + var editToken = GenerateEditToken(); + var editTokenHash = HashEditToken(editToken); + + var snippet = new Snippet + { + Id = id, + Source = source, + EditTokenHash = editTokenHash, + ParentId = parentId, + CreatedAt = now, + UpdatedAt = now + }; + + await SaveToS3Async(snippet); + + return new SaveSnippetResponse + { + Id = id, + EditToken = editToken, + IsNew = true + }; + } + + private async Task UpdateSnippetAsync(Snippet snippet, string source, string editToken, DateTime now) + { + snippet.Source = source; + snippet.UpdatedAt = now; + + await SaveToS3Async(snippet); + + return new SaveSnippetResponse + { + Id = snippet.Id, + EditToken = editToken, + IsNew = false + }; + } + + private async Task SaveToS3Async(Snippet snippet) + { + var json = JsonSerializer.Serialize(snippet, new JsonSerializerOptions + { + WriteIndented = true + }); + + var putRequest = new PutObjectRequest + { + BucketName = _bucketName, + Key = $"snippets/{snippet.Id}.json", + ContentBody = json, + ContentType = "application/json" + }; + + await _s3Client.PutObjectAsync(putRequest); + } + + private static string GenerateEditToken() + { + var bytes = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(bytes); + return Convert.ToBase64String(bytes); + } + + private static string HashEditToken(string token) + { + var bytes = Encoding.UTF8.GetBytes(token); + var hash = SHA256.HashData(bytes); + return Convert.ToBase64String(hash); + } + + private static bool VerifyEditToken(string token, string hash) + { + var computedHash = HashEditToken(token); + return computedHash == hash; + } + } +} + diff --git a/RadzenBlazorDemos.Host/appsettings.Development.json b/RadzenBlazorDemos.Host/appsettings.Development.json index f49584dc..510ca0d9 100644 --- a/RadzenBlazorDemos.Host/appsettings.Development.json +++ b/RadzenBlazorDemos.Host/appsettings.Development.json @@ -6,6 +6,14 @@ "Microsoft": "Information" } }, + "Playground": { + "S3": { + "ServiceUrl": "", + "BucketName": "radzen-playground", + "AccessKey": "", + "SecretKey": "" + } + }, "AIChatService": { "Endpoint": "https://api.cloudflare.com/client/v4/accounts/dac31e6601b57aa9edbead03210a6fd6/ai/v1/chat/completions", "ApiKey": "", diff --git a/RadzenBlazorDemos.Host/appsettings.json b/RadzenBlazorDemos.Host/appsettings.json index 3a07891c..72f125a1 100644 --- a/RadzenBlazorDemos.Host/appsettings.json +++ b/RadzenBlazorDemos.Host/appsettings.json @@ -7,6 +7,14 @@ } }, "AllowedHosts": "*", + "Playground": { + "S3": { + "ServiceUrl": "", + "BucketName": "radzen-playground", + "AccessKey": "", + "SecretKey": "" + } + }, "AIChatService": { "Endpoint": "https://api.cloudflare.com/client/v4/accounts/dac31e6601b57aa9edbead03210a6fd6/ai/v1/chat/completions", "ApiKey": "", diff --git a/RadzenBlazorDemos/Pages/Playground.razor b/RadzenBlazorDemos/Pages/Playground.razor new file mode 100644 index 00000000..1081adbe --- /dev/null +++ b/RadzenBlazorDemos/Pages/Playground.razor @@ -0,0 +1,308 @@ +@page "/playground" +@page "/playground/{Id}" +@layout PlaygroundLayout +@using System.Text.RegularExpressions +@using System.Text.Json +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@inject IJSRuntime JSRuntime +@inject HttpClient Http +@inject NavigationManager NavigationManager +@inject IServiceProvider ServiceProvider +@inject AntiforgeryStateProvider AntiforgeryStateProvider +@implements IDisposable + +Playground | Radzen Blazor Components + + + + + + + + Source Code + + + + + + + + + + @error + + Snippet saved! Share this URL: + @currentUrl + + + + + +
+ +
+
+
+ + + + + + Preview + + + +
+ @if (dynamicContent != null) + { + @dynamicContent + } + else + { + + Click "Run" to preview your code. + + } +
+
+
+
+ +@code { + private Monaco monaco; + private string source = DefaultSource; + private string error; + private bool isBusy; + private bool isSaving; + private bool saveSuccess; + private string currentUrl; + private RenderFragment dynamicContent; + private CompilerServiceFactory compilerFactory; + private string editToken; + + private const string DefaultSource = @" +@message + +@code { + string message = ""Click the button above!""; + + void OnClick() + { + message = $""Button clicked at {DateTime.Now:HH:mm:ss}""; + } +}"; + + [Parameter] + public string Id { get; set; } + + [SupplyParameterFromQuery] + [Parameter] + public string Example { get; set; } + + protected override async Task OnInitializedAsync() + { + // Load from saved snippet if Id is provided + if (!string.IsNullOrEmpty(Id)) + { + await LoadSnippetAsync(Id); + } + // Load from example if Example query param is provided + else if (!string.IsNullOrEmpty(Example)) + { + await LoadExampleAsync(Example); + } + } + + async Task LoadSnippetAsync(string id) + { + try + { + var response = await Http.GetAsync($"/api/playground/{id}"); + if (response.IsSuccessStatusCode) + { + var snippet = await response.Content.ReadFromJsonAsync(); + if (snippet != null) + { + source = snippet.Source; + // Try to get the edit token from localStorage + editToken = await GetEditTokenAsync(id); + } + } + } + catch + { + // Use default source if snippet cannot be loaded + } + } + + async Task LoadExampleAsync(string example) + { + try + { + var exampleSource = await Http.GetStringAsync($"/demos/Pages/{example}.txt"); + source = Clean(exampleSource); + } + catch + { + // Use default source if example cannot be loaded + } + } + + string Clean(string source) + { + return Regex.Replace(source, "]*>\n?", ""); + } + + void OnValueChanged(string value) + { + source = value; + saveSuccess = false; + } + + async Task Run() + { + try + { + error = null; + isBusy = true; + StateHasChanged(); + + await Task.Yield(); + + compilerFactory ??= new CompilerServiceFactory(ServiceProvider, NavigationManager); + var compiler = await compilerFactory.GetCompilerAsync(); + + var type = await compiler.CompileAsync(source); + + dynamicContent = builder => + { + builder.OpenComponent(0, type); + builder.CloseComponent(); + }; + } + catch (Exception ex) + { + error = ex.Message; + dynamicContent = null; + } + finally + { + isBusy = false; + } + } + + async Task Save() + { + try + { + error = null; + saveSuccess = false; + isSaving = true; + StateHasChanged(); + + var antiforgeryToken = AntiforgeryStateProvider.GetAntiforgeryToken(); + + var request = new + { + id = Id, + source = source, + editToken = editToken + }; + + var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/playground/save"); + httpRequest.Content = JsonContent.Create(request); + + if (antiforgeryToken?.Value != null) + { + httpRequest.Headers.Add("X-CSRF-TOKEN", antiforgeryToken.Value); + } + + var response = await Http.SendAsync(httpRequest); + + if (!response.IsSuccessStatusCode) + { + var errorResponse = await response.Content.ReadAsStringAsync(); + throw new Exception($"Failed to save: {errorResponse}"); + } + + var result = await response.Content.ReadFromJsonAsync(); + + if (result != null) + { + // Store the edit token in localStorage + await SetEditTokenAsync(result.Id, result.EditToken); + editToken = result.EditToken; + + // Navigate to the new URL if it's a new snippet or clone + if (result.IsNew || Id != result.Id) + { + Id = result.Id; + NavigationManager.NavigateTo($"/playground/{result.Id}", replace: false); + } + + currentUrl = NavigationManager.ToAbsoluteUri($"/playground/{result.Id}").ToString(); + saveSuccess = true; + } + } + catch (Exception ex) + { + error = ex.Message; + } + finally + { + isSaving = false; + } + } + + async Task CopyUrl() + { + if (!string.IsNullOrEmpty(currentUrl)) + { + await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", currentUrl); + } + } + + async Task GetEditTokenAsync(string id) + { + try + { + return await JSRuntime.InvokeAsync("localStorage.getItem", $"playground-token-{id}"); + } + catch + { + return null; + } + } + + async Task SetEditTokenAsync(string id, string token) + { + try + { + await JSRuntime.InvokeVoidAsync("localStorage.setItem", $"playground-token-{id}", token); + } + catch + { + // Ignore localStorage errors + } + } + + public void Dispose() + { + GC.Collect(); + } + + class SnippetResponse + { + public string Id { get; set; } + public string Source { get; set; } + public string ParentId { get; set; } + } + + class SaveResponse + { + public string Id { get; set; } + public string EditToken { get; set; } + public bool IsNew { get; set; } + } +} diff --git a/RadzenBlazorDemos/Services/CompilerServiceFactory.cs b/RadzenBlazorDemos/Services/CompilerServiceFactory.cs new file mode 100644 index 00000000..90374b93 --- /dev/null +++ b/RadzenBlazorDemos/Services/CompilerServiceFactory.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.WebAssembly.Services; + +namespace RadzenBlazorDemos +{ + public class CompilerServiceFactory + { + private static readonly string[] LazyAssemblies = new[] + { + "Microsoft.CodeAnalysis.dll", + "Microsoft.CodeAnalysis.CSharp.dll", + "Microsoft.CodeAnalysis.Razor.dll", + "Microsoft.AspNetCore.Razor.Language.dll", + "Microsoft.AspNetCore.Mvc.Razor.Extensions.dll", + "MetadataReferenceService.BlazorWasm.dll", + "MetadataReferenceService.Abstractions.dll" + }; + + private readonly IServiceProvider serviceProvider; + private readonly NavigationManager navigationManager; + private ICompilerService compiler; + + public CompilerServiceFactory(IServiceProvider serviceProvider, NavigationManager navigationManager) + { + this.serviceProvider = serviceProvider; + this.navigationManager = navigationManager; + } + + public async Task GetCompilerAsync() + { + if (compiler != null) + { + return compiler; + } + + var assemblyLoader = serviceProvider.GetService(typeof(LazyAssemblyLoader)) as LazyAssemblyLoader; + + if (assemblyLoader != null) + { + await assemblyLoader.LoadAssembliesAsync(LazyAssemblies); + } + + var compilerType = AppDomain.CurrentDomain.GetAssemblies() + .Select(a => a.GetType("RadzenBlazorDemos.CompilerService", false)) + .FirstOrDefault(t => t != null); + + compiler = (ICompilerService)Activator.CreateInstance(compilerType, navigationManager); + + return compiler; + } + } +} + diff --git a/RadzenBlazorDemos/Shared/CodeViewer.razor b/RadzenBlazorDemos/Shared/CodeViewer.razor index baef9c7e..11c40f59 100644 --- a/RadzenBlazorDemos/Shared/CodeViewer.razor +++ b/RadzenBlazorDemos/Shared/CodeViewer.razor @@ -4,7 +4,6 @@ @using System.Reflection; @using Microsoft.Extensions.DependencyInjection; @using System.Linq; -@using Microsoft.AspNetCore.Components.WebAssembly.Services @inject IJSRuntime JSRuntime @inject HttpClient Http @inject NavigationManager NavigationManager @@ -19,6 +18,7 @@ Copy + @@ -56,7 +56,7 @@ private string source; private string error; private string language = "razor"; - private ICompilerService compiler; + private CompilerServiceFactory compilerFactory; [Parameter] public EventCallback Compiled { get; set; } @@ -125,6 +125,8 @@ await OnValueChanged(source); } + + bool isBusy; async Task Run() @@ -137,7 +139,8 @@ await Task.Yield(); - await EnsureCompilerAsync(); + compilerFactory ??= new CompilerServiceFactory(ServiceProvider, NavigationManager); + var compiler = await compilerFactory.GetCompilerAsync(); var type = await compiler.CompileAsync(source); @@ -152,31 +155,4 @@ isBusy = false; await JSRuntime.InvokeVoidAsync("eval", $"instances['{monaco.Id}'].updateOptions({{domReadOnly: false, readOnly: false }})"); } - - async Task EnsureCompilerAsync() - { - if (compiler != null) - { - return; - } - - var assemblyLoader = ServiceProvider.GetService(); - - if (assemblyLoader != null) - { - await assemblyLoader.LoadAssembliesAsync(new[] - { - "Microsoft.CodeAnalysis.dll", - "Microsoft.CodeAnalysis.CSharp.dll", - "Microsoft.CodeAnalysis.Razor.dll", - "Microsoft.AspNetCore.Razor.Language.dll", - "Microsoft.AspNetCore.Mvc.Razor.Extensions.dll", - "MetadataReferenceService.BlazorWasm.dll", - "MetadataReferenceService.Abstractions.dll" - }); - } - - var compilerType = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("RadzenBlazorDemos.CompilerService", false)).FirstOrDefault(t => t != null); - compiler = (ICompilerService)Activator.CreateInstance(compilerType, NavigationManager); - } } diff --git a/RadzenBlazorDemos/Shared/Monaco.razor b/RadzenBlazorDemos/Shared/Monaco.razor index 2492d936..04b1a4c2 100644 --- a/RadzenBlazorDemos/Shared/Monaco.razor +++ b/RadzenBlazorDemos/Shared/Monaco.razor @@ -1,6 +1,6 @@ @inherits Radzen.RadzenComponent -
+
@code { IJSObjectReference monaco; ElementReference editor; diff --git a/RadzenBlazorDemos/Shared/PlaygroundLayout.razor b/RadzenBlazorDemos/Shared/PlaygroundLayout.razor new file mode 100644 index 00000000..ec912f2e --- /dev/null +++ b/RadzenBlazorDemos/Shared/PlaygroundLayout.razor @@ -0,0 +1,61 @@ +@using Microsoft.AspNetCore.Components + +@using Radzen.Blazor +@inherits LayoutComponentBase +@inject ThemeService ThemeService +@inject QueryStringThemeService QueryStringThemeService +@inject ExampleService ExampleService +@inject NavigationManager UriHelper +@inject IJSRuntime JSRuntime +@inject TooltipService TooltipService +@inject DialogService DialogService + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+
+ + @Body + +
+ +@if (!rendered) +{ +
+
+
+} + +@code { + bool rendered; + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + rendered = true; + StateHasChanged(); + } + } +} diff --git a/RadzenBlazorDemos/wwwroot/css/site.css b/RadzenBlazorDemos/wwwroot/css/site.css index f7f8e034..1ef8b15c 100644 --- a/RadzenBlazorDemos/wwwroot/css/site.css +++ b/RadzenBlazorDemos/wwwroot/css/site.css @@ -628,4 +628,15 @@ svg.illustration { inset-inline-start: 0; width: 100%; height: 100%; +} + +.code-editor { + height: 500px; + width: 100%; +} + + +.playground .code-editor { + height: 100%; + width: 100%; } \ No newline at end of file