diff --git a/Freman.Sample.Web.Contracts/CreateNoteRequest.cs b/Freman.Sample.Web.Contracts/CreateNoteRequest.cs new file mode 100644 index 0000000..6928aea --- /dev/null +++ b/Freman.Sample.Web.Contracts/CreateNoteRequest.cs @@ -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; } = ""; +} \ No newline at end of file diff --git a/Freman.Sample.Web.Contracts/Freman.Sample.Web.Contracts.csproj b/Freman.Sample.Web.Contracts/Freman.Sample.Web.Contracts.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/Freman.Sample.Web.Contracts/Freman.Sample.Web.Contracts.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Freman.Sample.Web.Contracts/NoteDto.cs b/Freman.Sample.Web.Contracts/NoteDto.cs new file mode 100644 index 0000000..d96cd50 --- /dev/null +++ b/Freman.Sample.Web.Contracts/NoteDto.cs @@ -0,0 +1,3 @@ +namespace Freman.Sample.Web.Contracts; + +public sealed record NoteDto(int Id, string Text, DateTime CreatedUtc); \ No newline at end of file diff --git a/Freman.Sample.Web.sln b/Freman.Sample.Web.sln index 170fe9c..5559a2f 100644 --- a/Freman.Sample.Web.sln +++ b/Freman.Sample.Web.sln @@ -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 diff --git a/Freman.Sample.Web/Freman.Sample.Web.Client/Components/ConfirmModal.razor b/Freman.Sample.Web/Freman.Sample.Web.Client/Components/ConfirmModal.razor new file mode 100644 index 0000000..fbfa6c7 --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web.Client/Components/ConfirmModal.razor @@ -0,0 +1,103 @@ +@if (_isOpen) +{ + + + +} + +@code { + private bool _isOpen; + private bool _isBusy; + + private string _title = "Confirm"; + private string _message = ""; + private string _confirmText = "Confirm"; + private string _cancelText = "Cancel"; + + private TaskCompletionSource? _tcs; + + public Task 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(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(); + } +} \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web.Client/Freman.Sample.Web.Client.csproj b/Freman.Sample.Web/Freman.Sample.Web.Client/Freman.Sample.Web.Client.csproj index 82f3fd3..5e9df70 100644 --- a/Freman.Sample.Web/Freman.Sample.Web.Client/Freman.Sample.Web.Client.csproj +++ b/Freman.Sample.Web/Freman.Sample.Web.Client/Freman.Sample.Web.Client.csproj @@ -24,4 +24,8 @@ + + + + diff --git a/Freman.Sample.Web/Freman.Sample.Web.Client/Pages/Notes.razor b/Freman.Sample.Web/Freman.Sample.Web.Client/Pages/Notes.razor new file mode 100644 index 0000000..fa6248f --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web.Client/Pages/Notes.razor @@ -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 + +Notes + +

Notes

+ +

+ Client-side UI (WASM) calling server API at /api/notes. +

+ + + +@if (!string.IsNullOrWhiteSpace(_error)) +{ + +} + + + + + +
+ + +
Max 500 characters.
+
+ + + + +
+ +
+ +@if (_isLoading) +{ +
+ + Loading… +
+} +else if (_notes.Count == 0) +{ +

No notes yet.

+} +else +{ +
    + @foreach (var n in _notes) + { +
  • +
    +
    @n.Text
    + @n.CreatedUtc.ToLocalTime() +
    + + +
  • + } +
+} + +@code { + + private readonly CreateNoteRequest _model = new(); + private List _notes = []; + private bool _isLoading = true; + private bool _isSaving; + private string? _error; + + private readonly HashSet _isDeletingIds = []; + + async protected override Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + _isLoading = true; + _error = null; + + try + { + _notes = await Http.GetFromJsonAsync>("/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); + } + } +} \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web.Client/Program.cs b/Freman.Sample.Web/Freman.Sample.Web.Client/Program.cs index 91db88a..b10acf0 100644 --- a/Freman.Sample.Web/Freman.Sample.Web.Client/Program.cs +++ b/Freman.Sample.Web/Freman.Sample.Web.Client/Program.cs @@ -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(); \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor b/Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor index d99a04e..474c0d5 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor +++ b/Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor @@ -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 -Notes +Notes (Server) -

Notes

+

Notes (Server)

-

- A tiny EF Core + PostgreSQL demo: type a note, save it, refresh, it’s still there. -

- -@if (_isLoading) -{ -
- - Loading notes… -
-} - -@if (!string.IsNullOrWhiteSpace(_error)) -{ - -} - - - - - -
- - -
Max 500 characters.
- -
- - - - -
- -@if (!string.IsNullOrWhiteSpace(_status)) -{ -
@_status
-} - -
- -

Saved notes

- -@if (_notesList.Count == 0) -{ -

No notes yet.

-} -else -{ -
    - @foreach (var n in _notesList) - { -
  • - @n.Text - @n.CreatedUtc.ToLocalTime() -
  • - } -
-} - -@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 _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; - } - } -} \ No newline at end of file +

+ This page is intentionally server-rendered. + For the main demo (client UI calling a server API), go to /notes. +

\ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs index 74c8f96..ac2dbfe 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs +++ b/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs @@ -4,13 +4,13 @@ namespace Freman.Sample.Web.Data; public sealed class AppDbContext(DbContextOptions options) : DbContext(options) { - public DbSet Notes => Set(); + public DbSet Notes => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - modelBuilder.Entity(b => + modelBuilder.Entity(b => { b.ToTable("notes"); b.HasKey(x => x.Id); diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/NoteEntity.cs similarity index 85% rename from Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs rename to Freman.Sample.Web/Freman.Sample.Web/Data/NoteEntity.cs index 68d1e12..376f4c1 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs +++ b/Freman.Sample.Web/Freman.Sample.Web/Data/NoteEntity.cs @@ -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; diff --git a/Freman.Sample.Web/Freman.Sample.Web/Endpoints/NotesEndpoints.cs b/Freman.Sample.Web/Freman.Sample.Web/Endpoints/NotesEndpoints.cs new file mode 100644 index 0000000..d128ed5 --- /dev/null +++ b/Freman.Sample.Web/Freman.Sample.Web/Endpoints/NotesEndpoints.cs @@ -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; + } +} \ No newline at end of file diff --git a/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj b/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj index 6daab93..b4de7df 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj +++ b/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj @@ -10,7 +10,7 @@ - + @@ -27,4 +27,8 @@ + + + + diff --git a/Freman.Sample.Web/Freman.Sample.Web/Program.cs b/Freman.Sample.Web/Freman.Sample.Web/Program.cs index ede5460..38b6274 100644 --- a/Freman.Sample.Web/Freman.Sample.Web/Program.cs +++ b/Freman.Sample.Web/Freman.Sample.Web/Program.cs @@ -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() .AddInteractiveServerRenderMode() diff --git a/README.md b/README.md index 9f9f430..46f3542 100644 --- a/README.md +++ b/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`. \ No newline at end of file +- **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. diff --git a/docs/ai/AI_CONTEXT.md b/docs/ai/AI_CONTEXT.md new file mode 100644 index 0000000..c3d7bf7 --- /dev/null +++ b/docs/ai/AI_CONTEXT.md @@ -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 \ No newline at end of file diff --git a/docs/ai/AI_TASKS.md b/docs/ai/AI_TASKS.md new file mode 100644 index 0000000..3ba8cf7 --- /dev/null +++ b/docs/ai/AI_TASKS.md @@ -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/Endpoints.cs` +2) Add an extension method: + - `public static IEndpointRouteBuilder MapEndpoints(this IEndpointRouteBuilder app)` +3) Call it from `Freman.Sample.Web/Program.cs`: + - `app.MapEndpoints();` + +## Add a new Client page that calls the API + +1) Create page in `Freman.Sample.Web.Client/Pages/.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")`