diff --git a/Freman.Sample.Web/Freman.Sample.Web/Components/Layout/NavMenu.razor b/Freman.Sample.Web/Freman.Sample.Web/Components/Layout/NavMenu.razor
index 4a7f343..2e8326e 100644
--- a/Freman.Sample.Web/Freman.Sample.Web/Components/Layout/NavMenu.razor
+++ b/Freman.Sample.Web/Freman.Sample.Web/Components/Layout/NavMenu.razor
@@ -25,5 +25,11 @@
Weather
+
+
+
+ Notes (Postgres)
+
+
\ 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
new file mode 100644
index 0000000..d99a04e
--- /dev/null
+++ b/Freman.Sample.Web/Freman.Sample.Web/Components/Pages/Notes.razor
@@ -0,0 +1,173 @@
+@page "/notes"
+@rendermode InteractiveServer
+@using Freman.Sample.Web.Data
+@using Microsoft.EntityFrameworkCore
+@using System.ComponentModel.DataAnnotations
+@inject AppDbContext Db
+
+Notes
+
+Notes
+
+
+ A tiny EF Core + PostgreSQL demo: type a note, save it, refresh, it’s still there.
+
+
+@if (_isLoading)
+{
+
+}
+
+@if (!string.IsNullOrWhiteSpace(_error))
+{
+
+ @_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
diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs
new file mode 100644
index 0000000..74c8f96
--- /dev/null
+++ b/Freman.Sample.Web/Freman.Sample.Web/Data/AppDbContext.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Freman.Sample.Web.Data;
+
+public sealed class AppDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Notes => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("notes");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.Text).HasMaxLength(500).IsRequired();
+ b.Property(x => x.CreatedUtc).IsRequired();
+ b.HasIndex(x => x.CreatedUtc);
+ });
+ }
+}
\ No newline at end of file
diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/DbMigrationHelper.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/DbMigrationHelper.cs
new file mode 100644
index 0000000..a11c09c
--- /dev/null
+++ b/Freman.Sample.Web/Freman.Sample.Web/Data/DbMigrationHelper.cs
@@ -0,0 +1,13 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Freman.Sample.Web.Data;
+
+public static class DbMigrationHelper
+{
+ public static async Task ApplyMigrationsAsync(IServiceProvider services, CancellationToken ct = default)
+ {
+ await using var scope = services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.MigrateAsync(ct);
+ }
+}
\ No newline at end of file
diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.Designer.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.Designer.cs
new file mode 100644
index 0000000..29f4012
--- /dev/null
+++ b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.Designer.cs
@@ -0,0 +1,53 @@
+//
+using System;
+using Freman.Sample.Web.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Freman.Sample.Web.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260222193339_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Freman.Sample.Web.Data.Note", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedUtc");
+
+ b.ToTable("notes", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.cs
new file mode 100644
index 0000000..bdaa1a4
--- /dev/null
+++ b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/20260222193339_InitialCreate.cs
@@ -0,0 +1,42 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Freman.Sample.Web.Data.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "notes",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ Text = table.Column(type: "character varying(500)", maxLength: 500, nullable: false),
+ CreatedUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_notes", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_notes_CreatedUtc",
+ table: "notes",
+ column: "CreatedUtc");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "notes");
+ }
+ }
+}
diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/AppDbContextModelSnapshot.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 0000000..8c60616
--- /dev/null
+++ b/Freman.Sample.Web/Freman.Sample.Web/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,50 @@
+//
+using System;
+using Freman.Sample.Web.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Freman.Sample.Web.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Freman.Sample.Web.Data.Note", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedUtc");
+
+ b.ToTable("notes", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs b/Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs
new file mode 100644
index 0000000..68d1e12
--- /dev/null
+++ b/Freman.Sample.Web/Freman.Sample.Web/Data/Note.cs
@@ -0,0 +1,8 @@
+namespace Freman.Sample.Web.Data;
+
+public sealed class Note
+{
+ public int Id { get; set; }
+ public string Text { get; set; } = string.Empty;
+ public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
+}
\ 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 446ee68..6daab93 100644
--- a/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj
+++ b/Freman.Sample.Web/Freman.Sample.Web/Freman.Sample.Web.csproj
@@ -10,13 +10,21 @@
-
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
-
- .dockerignore
-
+
+ .dockerignore
+
diff --git a/Freman.Sample.Web/Freman.Sample.Web/Program.cs b/Freman.Sample.Web/Freman.Sample.Web/Program.cs
index 5fc8df5..ede5460 100644
--- a/Freman.Sample.Web/Freman.Sample.Web/Program.cs
+++ b/Freman.Sample.Web/Freman.Sample.Web/Program.cs
@@ -1,5 +1,7 @@
using Freman.Sample.Web.Client.Pages;
using Freman.Sample.Web.Components;
+using Freman.Sample.Web.Data;
+using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
@@ -8,11 +10,20 @@ builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
+builder.Services.AddDbContext(options =>
+{
+ var cs = builder.Configuration.GetConnectionString("AppDb");
+ options.UseNpgsql(cs);
+});
+
+
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
+ // Auto-apply migrations for dev/demo convenience.
+ await DbMigrationHelper.ApplyMigrationsAsync(app.Services);
app.UseWebAssemblyDebugging();
}
else
diff --git a/Freman.Sample.Web/Freman.Sample.Web/appsettings.Development.json b/Freman.Sample.Web/Freman.Sample.Web/appsettings.Development.json
index 0c208ae..20cb0e2 100644
--- a/Freman.Sample.Web/Freman.Sample.Web/appsettings.Development.json
+++ b/Freman.Sample.Web/Freman.Sample.Web/appsettings.Development.json
@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
+ },
+ "ConnectionStrings": {
+ "AppDb": "Host=localhost;Port=5432;Database=freman_sample;Username=appuser;Password=CHANGE_ME"
}
}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9f9f430
--- /dev/null
+++ b/README.md
@@ -0,0 +1,47 @@
+# 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.
+
+## Prereqs
+
+- Docker Desktop
+- .NET SDK (matching the repo)
+- (Optional) EF tooling: `dotnet-ef`
+
+## Quickstart (recommended)
+
+1) Create a `.env` file next to `compose.yaml`:
+ ```text POSTGRES_PASSWORD=your_local_dev_password```
+
+2) Start everything:
+ ```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.
+
+## Running from Rider (server on host, DB in Docker)
+
+Start just Postgres:
+
+```powershell docker compose up postgres```
+
+Then run the `Freman.Sample.Web` project from Rider.
+
+> In this mode, the app uses `appsettings.Development.json` which points to `localhost:5432`.
+
+## Migrations (EF Core)
+
+Install EF CLI once:
+```powershell dotnet tool install --global dotnet-ef```
+
+Add a migration:
+
+```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```
+
+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```
+
+## 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
diff --git a/compose.yaml b/compose.yaml
index 7501eb8..6ef072b 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,9 +1,33 @@
services:
+ postgres:
+ image: postgres:16
+ container_name: freman.sample.postgres
+ environment:
+ POSTGRES_DB: freman_sample
+ POSTGRES_USER: appuser
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-CHANGE_ME}
+ ports:
+ - "5432:5432"
+ volumes:
+ - freman_sample_pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U appuser -d freman_sample" ]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
freman.sample.web:
image: freman.sample.web
build:
context: .
dockerfile: Freman.Sample.Web/Freman.Sample.Web/Dockerfile
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ ASPNETCORE_ENVIRONMENT: Development
+ ConnectionStrings__AppDb: Host=postgres;Port=5432;Database=freman_sample;Username=appuser;Password=${POSTGRES_PASSWORD:-CHANGE_ME}
+
freman.sample.web.client:
image: freman.sample.web.client
@@ -11,3 +35,5 @@
context: .
dockerfile: Freman.Sample.Web/Freman.Sample.Web.Client/Dockerfile
+volumes:
+ freman_sample_pgdata:
\ No newline at end of file
diff --git a/dotnet-tools.json b/dotnet-tools.json
new file mode 100644
index 0000000..bffb60c
--- /dev/null
+++ b/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "10.0.3",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file