Add client-side Notes page with API integration and shared contracts
This commit is contained in:
10
Freman.Sample.Web.Contracts/CreateNoteRequest.cs
Normal file
10
Freman.Sample.Web.Contracts/CreateNoteRequest.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Freman.Sample.Web.Contracts;
|
||||
|
||||
public sealed class CreateNoteRequest
|
||||
{
|
||||
[Required]
|
||||
[StringLength(500)]
|
||||
public string Text { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
3
Freman.Sample.Web.Contracts/NoteDto.cs
Normal file
3
Freman.Sample.Web.Contracts/NoteDto.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace Freman.Sample.Web.Contracts;
|
||||
|
||||
public sealed record NoteDto(int Id, string Text, DateTime CreatedUtc);
|
||||
@@ -9,6 +9,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||
compose.yaml = compose.yaml
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Freman.Sample.Web.Contracts", "Freman.Sample.Web.Contracts\Freman.Sample.Web.Contracts.csproj", "{E92F3324-DD83-4B66-BF80-B287C6C5597D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -23,5 +25,9 @@ Global
|
||||
{7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7FCB89A6-518D-404C-B782-9A5A6F86FEC5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E92F3324-DD83-4B66-BF80-B287C6C5597D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E92F3324-DD83-4B66-BF80-B287C6C5597D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E92F3324-DD83-4B66-BF80-B287C6C5597D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E92F3324-DD83-4B66-BF80-B287C6C5597D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
@if (_isOpen)
|
||||
{
|
||||
<div class="modal fade show"
|
||||
style="display: block;"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@onkeydown="OnKeyDown">
|
||||
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">@_title</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="mb-0">@_message</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel" disabled="@_isBusy">
|
||||
@_cancelText
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="Confirm" disabled="@_isBusy">
|
||||
@_confirmText
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool _isOpen;
|
||||
private bool _isBusy;
|
||||
|
||||
private string _title = "Confirm";
|
||||
private string _message = "";
|
||||
private string _confirmText = "Confirm";
|
||||
private string _cancelText = "Cancel";
|
||||
|
||||
private TaskCompletionSource<bool>? _tcs;
|
||||
|
||||
public Task<bool> ShowAsync(
|
||||
string title,
|
||||
string message,
|
||||
string confirmText = "Delete",
|
||||
string cancelText = "Cancel")
|
||||
{
|
||||
// If already open, complete the previous request as "false"
|
||||
_tcs?.TrySetResult(false);
|
||||
|
||||
_title = title;
|
||||
_message = message;
|
||||
_confirmText = confirmText;
|
||||
_cancelText = cancelText;
|
||||
|
||||
_isBusy = false;
|
||||
_isOpen = true;
|
||||
|
||||
_tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
StateHasChanged();
|
||||
|
||||
return _tcs.Task;
|
||||
}
|
||||
|
||||
private void Confirm()
|
||||
{
|
||||
if (!_isOpen) return;
|
||||
|
||||
_isBusy = true;
|
||||
StateHasChanged();
|
||||
|
||||
Close(true);
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
if (!_isOpen) return;
|
||||
Close(false);
|
||||
}
|
||||
|
||||
private void Close(bool result)
|
||||
{
|
||||
_isOpen = false;
|
||||
_isBusy = false;
|
||||
|
||||
var tcs = _tcs;
|
||||
_tcs = null;
|
||||
|
||||
StateHasChanged();
|
||||
tcs?.TrySetResult(result);
|
||||
}
|
||||
|
||||
private void OnKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key is "Escape")
|
||||
Cancel();
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,8 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Freman.Sample.Web.Contracts\Freman.Sample.Web.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
217
Freman.Sample.Web/Freman.Sample.Web.Client/Pages/Notes.razor
Normal file
217
Freman.Sample.Web/Freman.Sample.Web.Client/Pages/Notes.razor
Normal file
@@ -0,0 +1,217 @@
|
||||
@page "/notes"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||
@using Freman.Sample.Web.Contracts
|
||||
@using Freman.Sample.Web.Client.Components
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>Notes</PageTitle>
|
||||
|
||||
<h1>Notes</h1>
|
||||
|
||||
<p class="text-muted">
|
||||
Client-side UI (WASM) calling server API at <code>/api/notes</code>.
|
||||
</p>
|
||||
|
||||
<ConfirmModal @ref="_confirmModal"/>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@_error</div>
|
||||
}
|
||||
|
||||
<EditForm Model="_model" OnValidSubmit="SaveAsync">
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary/>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="noteText">New note</label>
|
||||
<input id="noteText"
|
||||
class="form-control"
|
||||
@bind-value="_model.Text"
|
||||
@bind-value:event="oninput"
|
||||
maxlength="500"
|
||||
disabled="@(_isSaving || _isLoading)"/>
|
||||
<div class="form-text">Max 500 characters.</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" type="submit" disabled="@(_isSaving || _isLoading)">
|
||||
@if (_isSaving)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span>Saving…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Save</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-secondary ms-2" type="button" @onclick="LoadAsync"
|
||||
disabled="@(_isSaving || _isLoading)">
|
||||
Refresh
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<hr/>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></div>
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
}
|
||||
else if (_notes.Count == 0)
|
||||
{
|
||||
<p><em>No notes yet.</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="list-group">
|
||||
@foreach (var n in _notes)
|
||||
{
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="me-3">
|
||||
<div>@n.Text</div>
|
||||
<small class="text-muted">@n.CreatedUtc.ToLocalTime()</small>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
type="button"
|
||||
title="Delete note"
|
||||
disabled="@(_isSaving || _isLoading || _isDeletingIds.Contains(n.Id))"
|
||||
@onclick="() => DeleteAsync(n)">
|
||||
@if (_isDeletingIds.Contains(n.Id))
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Delete</span>
|
||||
}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
private readonly CreateNoteRequest _model = new();
|
||||
private List<NoteDto> _notes = [];
|
||||
private bool _isLoading = true;
|
||||
private bool _isSaving;
|
||||
private string? _error;
|
||||
|
||||
private readonly HashSet<int> _isDeletingIds = [];
|
||||
|
||||
async protected override Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
|
||||
try
|
||||
{
|
||||
_notes = await Http.GetFromJsonAsync<List<NoteDto>>("/api/notes") ?? [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Load failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_isSaving = true;
|
||||
_error = null;
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await Http.PostAsJsonAsync("/api/notes", new { text = _model.Text });
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var msg = await resp.Content.ReadAsStringAsync();
|
||||
_error = string.IsNullOrWhiteSpace(msg) ? $"Save failed: {resp.StatusCode}" : msg;
|
||||
return;
|
||||
}
|
||||
|
||||
_model.Text = "";
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Save failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private ConfirmModal? _confirmModal;
|
||||
|
||||
private async Task DeleteAsync(NoteDto note)
|
||||
{
|
||||
if (_confirmModal is null)
|
||||
{
|
||||
_error = "Confirm modal not available.";
|
||||
return;
|
||||
}
|
||||
|
||||
var ok = await _confirmModal.ShowAsync(
|
||||
title: "Delete note?",
|
||||
message: $"Are you sure you want to delete:\n\n\"{note.Text}\"",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel");
|
||||
|
||||
if (!ok)
|
||||
return;
|
||||
|
||||
if (!_isDeletingIds.Add(note.Id))
|
||||
return;
|
||||
|
||||
_error = null;
|
||||
|
||||
var index = _notes.FindIndex(n => n.Id == note.Id);
|
||||
if (index < 0)
|
||||
{
|
||||
_isDeletingIds.Remove(note.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic UI: remove immediately
|
||||
_notes.RemoveAt(index);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await Http.DeleteAsync($"/api/notes/{note.Id}");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
// Roll back optimistic delete
|
||||
_notes.Insert(index, note);
|
||||
|
||||
var msg = await resp.Content.ReadAsStringAsync();
|
||||
_error = string.IsNullOrWhiteSpace(msg) ? $"Delete failed: {resp.StatusCode}" : msg;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Roll back optimistic delete
|
||||
_notes.Insert(index, note);
|
||||
_error = $"Delete failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isDeletingIds.Remove(note.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,9 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.Services.AddScoped(_ => new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
|
||||
});
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -1,173 +1,11 @@
|
||||
@page "/notes"
|
||||
@page "/notes-server"
|
||||
@rendermode InteractiveServer
|
||||
@using Freman.Sample.Web.Data
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@inject AppDbContext Db
|
||||
|
||||
<PageTitle>Notes</PageTitle>
|
||||
<PageTitle>Notes (Server)</PageTitle>
|
||||
|
||||
<h1>Notes</h1>
|
||||
<h1>Notes (Server)</h1>
|
||||
|
||||
<p class="text-muted">
|
||||
A tiny EF Core + PostgreSQL demo: type a note, save it, refresh, it’s still there.
|
||||
</p>
|
||||
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<div class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></div>
|
||||
<span>Loading notes…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">
|
||||
@_error
|
||||
</div>
|
||||
}
|
||||
|
||||
<EditForm Model="_model" OnValidSubmit="SaveAsync">
|
||||
<DataAnnotationsValidator/>
|
||||
<ValidationSummary/>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="noteText">New note</label>
|
||||
<input id="noteText"
|
||||
class="form-control"
|
||||
@bind-value="_model.Text"
|
||||
@bind-value:event="oninput"
|
||||
maxlength="500"
|
||||
disabled="@(_isSaving || _isLoading)"/>
|
||||
<div class="form-text">Max 500 characters.</div>
|
||||
<ValidationMessage For="@(() => _model.Text)"/>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary"
|
||||
type="submit"
|
||||
disabled="@(_isSaving || _isLoading || string.IsNullOrWhiteSpace(_model.Text))">
|
||||
@if (_isSaving)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
<span>Saving…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Save</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-secondary ms-2"
|
||||
type="button"
|
||||
@onclick="LoadAsync"
|
||||
disabled="@(_isSaving || _isLoading)">
|
||||
Refresh
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_status))
|
||||
{
|
||||
<div class="mt-3 alert alert-info">@_status</div>
|
||||
}
|
||||
|
||||
<hr/>
|
||||
|
||||
<h2>Saved notes</h2>
|
||||
|
||||
@if (_notesList.Count == 0)
|
||||
{
|
||||
<p><em>No notes yet.</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="list-group">
|
||||
@foreach (var n in _notesList)
|
||||
{
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>@n.Text</span>
|
||||
<small class="text-muted">@n.CreatedUtc.ToLocalTime()</small>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
private sealed class NewNoteModel
|
||||
{
|
||||
[Required(ErrorMessage = "Please enter some text.")]
|
||||
[StringLength(500, ErrorMessage = "Notes are limited to 500 characters.")]
|
||||
public string Text { get; set; } = "";
|
||||
}
|
||||
|
||||
private readonly NewNoteModel _model = new();
|
||||
private bool _isSaving;
|
||||
private bool _isLoading;
|
||||
private string? _status;
|
||||
private string? _error;
|
||||
private List<Note> _notesList = [];
|
||||
|
||||
async protected override Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
_status = null;
|
||||
|
||||
try
|
||||
{
|
||||
_notesList = await Db.Notes
|
||||
.OrderByDescending(n => n.CreatedUtc)
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Load failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_isSaving = true;
|
||||
_error = null;
|
||||
_status = null;
|
||||
|
||||
var note = new Note
|
||||
{
|
||||
Text = _model.Text.Trim(),
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Optimistic UI: show it immediately
|
||||
_notesList.Insert(0, note);
|
||||
|
||||
try
|
||||
{
|
||||
Db.Notes.Add(note);
|
||||
await Db.SaveChangesAsync();
|
||||
|
||||
_model.Text = "";
|
||||
_status = "Saved!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Roll back optimistic insert
|
||||
_notesList.Remove(note);
|
||||
_error = $"Save failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
<p>
|
||||
This page is intentionally server-rendered.
|
||||
For the main demo (client UI calling a server API), go to <a href="/notes">/notes</a>.
|
||||
</p>
|
||||
@@ -4,13 +4,13 @@ namespace Freman.Sample.Web.Data;
|
||||
|
||||
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Note> Notes => Set<Note>();
|
||||
public DbSet<NoteEntity> Notes => Set<NoteEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Note>(b =>
|
||||
modelBuilder.Entity<NoteEntity>(b =>
|
||||
{
|
||||
b.ToTable("notes");
|
||||
b.HasKey(x => x.Id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Freman.Sample.Web.Data;
|
||||
|
||||
public sealed class Note
|
||||
public sealed class NoteEntity
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
@@ -0,0 +1,59 @@
|
||||
using Freman.Sample.Web.Contracts;
|
||||
using Freman.Sample.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Freman.Sample.Web.Endpoints;
|
||||
|
||||
public static class NotesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNotesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var notesApi = app.MapGroup("/api/notes");
|
||||
|
||||
notesApi.MapGet("/", async (AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var notes = await db.Notes
|
||||
.OrderByDescending(n => n.CreatedUtc)
|
||||
.Take(50)
|
||||
.Select(n => new NoteDto(n.Id, n.Text, n.CreatedUtc))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(notes);
|
||||
});
|
||||
|
||||
notesApi.MapPost("/", async (CreateNoteRequest request, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var text = (request.Text ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return Results.BadRequest("Text is required.");
|
||||
|
||||
if (text.Length > 500)
|
||||
return Results.BadRequest("Text must be 500 characters or less.");
|
||||
|
||||
var note = new NoteEntity
|
||||
{
|
||||
Text = text,
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Notes.Add(note);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Created($"/api/notes/{note.Id}", new NoteDto(note.Id, note.Text, note.CreatedUtc));
|
||||
});
|
||||
|
||||
notesApi.MapDelete("/{id:int}", async (int id, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var note = await db.Notes.FirstOrDefaultAsync(n => n.Id == id, ct);
|
||||
if (note is null)
|
||||
return Results.NotFound();
|
||||
|
||||
db.Notes.Remove(note);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Freman.Sample.Web.Client\Freman.Sample.Web.Client.csproj"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.App.Internal.Assets" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.App.Internal.Assets" Version="10.0.3"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.3"/>
|
||||
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3"/>
|
||||
@@ -27,4 +27,8 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Freman.Sample.Web.Contracts\Freman.Sample.Web.Contracts.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Freman.Sample.Web.Client.Pages;
|
||||
using Freman.Sample.Web.Components;
|
||||
using Freman.Sample.Web.Data;
|
||||
using Freman.Sample.Web.Endpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -38,6 +39,9 @@ app.UseHttpsRedirection();
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
// Map feature endpoints
|
||||
app.MapNotesEndpoints();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
|
||||
164
README.md
164
README.md
@@ -1,47 +1,171 @@
|
||||
# Freman.Sample.Web – Blazor + PostgreSQL (Docker) sample
|
||||
|
||||
This repo is a small demo for C# developers (e.g., Unity folks) who are new to web dev.
|
||||
This repo is a small demo for C# developers who are new to web development. The goal is a **“clone → run → it works”** experience, while also showing common real-world patterns:
|
||||
- PostgreSQL in Docker
|
||||
- EF Core migrations
|
||||
- A Blazor WebAssembly UI that talks to the server via HTTP APIs
|
||||
- A confirmation modal + optimistic UI updates
|
||||
|
||||
---
|
||||
|
||||
## What’s in this solution?
|
||||
|
||||
### Projects
|
||||
- **`Freman.Sample.Web` (Server / Host)**
|
||||
- ASP.NET Core app
|
||||
- Owns **PostgreSQL + EF Core** (DbContext, entities, migrations)
|
||||
- Exposes HTTP endpoints under `/api/*` (Minimal APIs)
|
||||
- Hosts the Blazor app and serves the WASM client
|
||||
|
||||
- **`Freman.Sample.Web.Client` (Client / Browser)**
|
||||
- Blazor WebAssembly UI
|
||||
- Calls the server API with `HttpClient`
|
||||
- Contains the demo UI page at `/notes`
|
||||
- Includes a reusable confirmation modal component (`ConfirmModal.razor`)
|
||||
|
||||
- **`Freman.Sample.Web.Contracts` (Shared contracts)**
|
||||
- Shared request/response models (“DTOs”) used by **both** server and client
|
||||
- Keeps the API “shape” consistent and avoids duplicating types
|
||||
|
||||
> Rule of thumb: **DB access and secrets stay on the server**. The client only calls HTTP APIs.
|
||||
|
||||
### Demo features
|
||||
- `/notes` page:
|
||||
- Create a note (validation + loading/saving UI)
|
||||
- List notes (loaded from `/api/notes`)
|
||||
- Delete a note (with confirmation modal)
|
||||
- Uses “optimistic UI” for delete (removes from list immediately, rolls back if the API fails)
|
||||
|
||||
---
|
||||
|
||||
## Shared Contracts (why `Freman.Sample.Web.Contracts` exists)
|
||||
|
||||
In “real” web apps, the server and client often need to agree on the JSON payloads they send each other. If you define those types twice (once in Server and once in Client), they will eventually drift.
|
||||
|
||||
This sample uses **`Freman.Sample.Web.Contracts`** to share:
|
||||
- `NoteDto` (what the API returns)
|
||||
- `CreateNoteRequest` (what the API accepts when creating a note)
|
||||
- Validation attributes (Data Annotations) that work on both sides
|
||||
|
||||
### Why EF entities are NOT shared
|
||||
EF Core entities (and DbContext) stay in the **Server** project because:
|
||||
- They represent persistence concerns (tables/relationships), not API contracts
|
||||
- They can include server-only behavior and configuration
|
||||
- You generally don’t want browsers depending on your database schema
|
||||
|
||||
So the flow is:
|
||||
- **Entity (Server-only)** ↔ mapped to ↔ **DTO (Contracts)** ↔ sent to ↔ **Client**
|
||||
|
||||
---
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker Desktop
|
||||
- .NET SDK (matching the repo)
|
||||
- (Optional) EF tooling: `dotnet-ef`
|
||||
|
||||
## Quickstart (recommended)
|
||||
---
|
||||
|
||||
## Quickstart (recommended): run everything with Docker Compose
|
||||
|
||||
1) Create a `.env` file next to `compose.yaml`:
|
||||
```text POSTGRES_PASSWORD=your_local_dev_password```
|
||||
```
|
||||
POSTGRES_PASSWORD=your_local_dev_password
|
||||
```
|
||||
|
||||
2) Start everything:
|
||||
```powershell docker compose up --build```
|
||||
```
|
||||
powershell docker compose up --build
|
||||
```
|
||||
|
||||
3) Open the app, then navigate to **Notes (Postgres)**.
|
||||
Type a note, click **Save**, refresh the page — it should persist.
|
||||
3) Open the app and navigate to `/notes`.
|
||||
Create a note, refresh the page — it should persist.
|
||||
|
||||
---
|
||||
|
||||
## Running from Rider (server on host, DB in Docker)
|
||||
|
||||
Start just Postgres:
|
||||
Start just PostgreSQL:
|
||||
```
|
||||
powershell docker compose up postgres
|
||||
```
|
||||
|
||||
```powershell docker compose up postgres```
|
||||
Then run the **`Freman.Sample.Web`** project from VS/Rider.
|
||||
|
||||
Then run the `Freman.Sample.Web` project from Rider.
|
||||
In this mode, the server uses the connection string in `Freman.Sample.Web/appsettings.Development.json`
|
||||
(which points to `localhost:5432`).
|
||||
|
||||
> In this mode, the app uses `appsettings.Development.json` which points to `localhost:5432`.
|
||||
---
|
||||
|
||||
## Migrations (EF Core)
|
||||
## Database + EF Core migrations
|
||||
|
||||
Install EF CLI once:
|
||||
```powershell dotnet tool install --global dotnet-ef```
|
||||
### About migrations in this repo
|
||||
This sample includes EF Core migrations and (for developer convenience) the server applies migrations automatically on startup in **Development**.
|
||||
|
||||
Add a migration:
|
||||
### EF CLI setup
|
||||
We use a **local** tool manifest (repo-friendly):
|
||||
```
|
||||
powershell dotnet tool restore
|
||||
```
|
||||
|
||||
```powershell dotnet ef migrations add InitialCreate --project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj --startup-project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj ` --output-dir Data\Migrations```
|
||||
If you prefer global installation instead, you can install/update `dotnet-ef` globally.
|
||||
|
||||
Apply it:
|
||||
```powershell dotnet ef database update --project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj --startup-project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj```
|
||||
### Add a migration
|
||||
Run from the solution root:
|
||||
```powershell dotnet tool run dotnet-ef -- migrations add InitialCreate --project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj --startup-project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj ` --output-dir Data\Migrations```
|
||||
|
||||
### Apply migrations manually (optional)
|
||||
Normally the server will apply migrations automatically in Development, but you can also run:
|
||||
```
|
||||
powershell dotnet tool run dotnet-ef -- database update --project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj --startup-project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API overview
|
||||
|
||||
- `GET /api/notes` → list recent notes
|
||||
- `POST /api/notes` → create note
|
||||
- `DELETE /api/notes/{id}` → delete note
|
||||
|
||||
The client calls these endpoints using `HttpClient` and JSON.
|
||||
|
||||
---
|
||||
|
||||
## Validation notes
|
||||
|
||||
The `/notes` form uses **Data Annotations** (`[Required]`, `[StringLength]`) because they’re simple and familiar.
|
||||
|
||||
If you outgrow Data Annotations, a common next step is **FluentValidation**:
|
||||
- more expressive rules
|
||||
- better composition
|
||||
- easier conditional validation and custom rule sets
|
||||
|
||||
---
|
||||
|
||||
## Render mode / hosting notes (important!)
|
||||
|
||||
This solution uses a mixed Blazor setup. For pages meant to run in the browser (WASM) and use the client DI container (including the WASM `HttpClient`), the page uses:
|
||||
|
||||
- `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`
|
||||
|
||||
Why `prerender: false`?
|
||||
- Without it, the component may be instantiated on the server during prerender, where `HttpClient` is not registered the same way and injection can fail.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **DB auth failed**: check `.env` password matches the connection string in `compose.yaml`.
|
||||
- **Port 5432 already in use**: stop the other Postgres instance or change the port mapping in `compose.yaml`.
|
||||
- **DB auth failed**
|
||||
- Ensure `.env` password matches the value used by Compose.
|
||||
- If you changed the password after the volume was created, you may need to reset the volume:
|
||||
- stop containers
|
||||
- remove the named volume
|
||||
- start again
|
||||
|
||||
- **Port 5432 already in use**
|
||||
- Stop any other local PostgreSQL instance or change the port mapping in `compose.yaml`.
|
||||
|
||||
- **Migrations/EF errors like “Unable to retrieve project metadata”**
|
||||
- Make sure the terminal is using the correct .NET SDK for this repo.
|
||||
- Ensure the `dotnet-ef` tool version matches the EF Core version used by the project.
|
||||
- Run `dotnet tool restore` from the repo root.
|
||||
|
||||
52
docs/ai/AI_CONTEXT.md
Normal file
52
docs/ai/AI_CONTEXT.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# AI_CONTEXT.md
|
||||
|
||||
This repository is a Blazor sample that demonstrates a real-world “browser UI calls server API” architecture with PostgreSQL.
|
||||
|
||||
## Solution layout
|
||||
- `Freman.Sample.Web` (Server / Host)
|
||||
- ASP.NET Core host
|
||||
- EF Core + PostgreSQL
|
||||
- Minimal API endpoints under `/api/*`
|
||||
- Applies migrations automatically in Development
|
||||
- `Freman.Sample.Web.Client` (Client / Browser)
|
||||
- Blazor WebAssembly UI
|
||||
- Uses `HttpClient` to call server endpoints
|
||||
- `/notes` page is WASM interactive with `prerender: false`
|
||||
- Reusable UI components live under `Client/Components` (e.g., `ConfirmModal.razor`)
|
||||
- `Freman.Sample.Web.Contracts` (Shared)
|
||||
- DTOs + request models shared between server and client
|
||||
- Examples: `NoteDto`, `CreateNoteRequest`
|
||||
|
||||
## Run modes (important)
|
||||
This solution uses mixed render modes.
|
||||
|
||||
- Client pages that inject `HttpClient` should run in the browser:
|
||||
- `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`
|
||||
- Server-only pages can use:
|
||||
- `@rendermode InteractiveServer`
|
||||
|
||||
Why `prerender: false`?
|
||||
- Without it, the component may be instantiated on the server during prerender, where client-only DI services (like the WASM `HttpClient`) are not available.
|
||||
|
||||
## How to run (happy path)
|
||||
- Create `.env` next to `compose.yaml`:
|
||||
- `POSTGRES_PASSWORD=...`
|
||||
- Run:
|
||||
- `docker compose up --build`
|
||||
- Navigate to:
|
||||
- `/notes`
|
||||
|
||||
## API
|
||||
- `GET /api/notes`
|
||||
- `POST /api/notes`
|
||||
- `DELETE /api/notes/{id}`
|
||||
|
||||
## Data access
|
||||
- EF Core DbContext: `AppDbContext` (server)
|
||||
- Entities are server-only (do not share with Client)
|
||||
- Contracts are shared via `Freman.Sample.Web.Contracts`
|
||||
|
||||
## Conventions
|
||||
- Server endpoints live in `Freman.Sample.Web/Endpoints/*` and are mapped from `Program.cs`
|
||||
- Prefer small, feature-scoped endpoint files (e.g., `NotesEndpoints.cs`)
|
||||
- Avoid duplicating DTOs in Client; use `Freman.Sample.Web.Contracts` instead
|
||||
47
docs/ai/AI_TASKS.md
Normal file
47
docs/ai/AI_TASKS.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# AI_TASKS.md
|
||||
|
||||
Quick task cookbook for common changes in this repo.
|
||||
|
||||
## Add a new API endpoint group
|
||||
|
||||
1) Create `Freman.Sample.Web/Endpoints/<Feature>Endpoints.cs`
|
||||
2) Add an extension method:
|
||||
- `public static IEndpointRouteBuilder Map<Feature>Endpoints(this IEndpointRouteBuilder app)`
|
||||
3) Call it from `Freman.Sample.Web/Program.cs`:
|
||||
- `app.Map<Feature>Endpoints();`
|
||||
|
||||
## Add a new Client page that calls the API
|
||||
|
||||
1) Create page in `Freman.Sample.Web.Client/Pages/<Page>.razor`
|
||||
2) Ensure it runs as WASM and avoids server prerender DI issues:
|
||||
- `@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))`
|
||||
3) Inject `HttpClient`:
|
||||
- `@inject HttpClient Http`
|
||||
4) Call server endpoints under `/api/*`
|
||||
|
||||
## Add shared request/response models
|
||||
|
||||
1) Add to `Freman.Sample.Web.Contracts`
|
||||
2) Reference contracts project from Server + Client
|
||||
3) Use contracts types in endpoints + client code (avoid duplicates)
|
||||
|
||||
## Add an EF Core migration
|
||||
|
||||
From solution root:
|
||||
|
||||
```
|
||||
powershell dotnet tool restore
|
||||
dotnet tool run dotnet-ef -- migrations add --project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj --startup-project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj ` --output-dir Data\Migrations
|
||||
```
|
||||
|
||||
## Apply migrations manually (optional)
|
||||
|
||||
```
|
||||
powershell dotnet tool run dotnet-ef -- database update --project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj --startup-project .\Freman.Sample.Web\Freman.Sample.Web\Freman.Sample.Web.csproj
|
||||
```
|
||||
|
||||
## Add a confirmation step in the client
|
||||
|
||||
Use `Freman.Sample.Web.Client/Components/ConfirmModal.razor` and call:
|
||||
|
||||
- `await _confirmModal.ShowAsync("Title", "Message")`
|
||||
Reference in New Issue
Block a user