Compare commits

..

22 Commits

Author SHA1 Message Date
EugeneTes
800b8b3021 Add CLAUDE.md documenting the repo as a Supabase boilerplate 2026-08-15 12:31:06 +00:00
EugeneTes
72248090e8 Ignore deploy.json (holds app secrets for Coolify) 2026-08-15 12:17:28 +00:00
EugeneTes
46a5f40f23 Add Dockerfile + docker-compose for Coolify deployment
Multi-stage build: node builds the SPA, dotnet publishes the API, the
runtime image serves the SPA from wwwroot/ and exposes /health. Frontend
Supabase URL + publishable key are baked in at build time; DB conn string
and Supabase JWKS metadata come from Coolify env vars. VITE_API_URL empty
means same-origin, so the browser hits /api/todos on the same host that
serves the SPA.
2026-08-15 12:10:45 +00:00
EugeneTes
b5f4cf5ee6 Update README with real setup instructions 2026-08-15 12:04:30 +00:00
EugeneTes
42a739ab35 Minimize Vite template's index.css to avoid clashing with inline styles
The scaffolded template shipped an opinionated marketing-page CSS
(fixed #root width, large h1 sizes, letter-spacing) that made the
TodoList header overlap. Replaced with a minimal reset so component
inline styles render as intended.
2026-08-15 12:03:56 +00:00
EugeneTes
41ac4fd662 Route between sign-in and todo list based on session 2026-08-15 11:57:09 +00:00
EugeneTes
77511a02af Add TodoList component 2026-08-15 11:56:12 +00:00
EugeneTes
aa27ac1142 Add authenticated fetch wrapper for the todos API 2026-08-15 11:55:09 +00:00
EugeneTes
cbcba53943 Add sign-in / sign-up form 2026-08-15 11:54:19 +00:00
EugeneTes
676b3b3a86 Add AuthProvider tracking Supabase session 2026-08-15 11:53:22 +00:00
EugeneTes
22240c89e8 Add Supabase client and env template 2026-08-15 11:52:31 +00:00
EugeneTes
94fe6d3358 Switch backend port from 5000 to 5057
Port 5000 caused recurring collisions with stray processes across subagent
runs. 5057 is uncontested in this environment. CORS unaffected (Vite dev
origin is 5173).
2026-08-15 11:51:23 +00:00
EugeneTes
a1ac658fce Scaffold Vite + React + TypeScript frontend 2026-08-15 11:50:53 +00:00
EugeneTes
a2b78fc856 Enable CORS for the Vite dev origin 2026-08-15 11:35:34 +00:00
EugeneTes
5d9eef56ff Add TodosController with GET/POST/PATCH/DELETE endpoints 2026-08-15 11:33:42 +00:00
EugeneTes
73676d3d01 Configure JWT bearer auth against Supabase JWKS via OpenID metadata 2026-08-15 11:32:16 +00:00
EugeneTes
00cba42ba4 Plan: pin NuGet package versions to 9.* for net9.0 compat 2026-08-15 11:30:49 +00:00
EugeneTes
1e2a60880e Add EF Core, Npgsql, Todo entity, and AppDbContext 2026-08-15 11:29:39 +00:00
EugeneTes
f7b0b7fb59 Scaffold ASP.NET Core Web API backend 2026-08-15 11:26:23 +00:00
EugeneTes
fd9ff55ffa Plan: use Supabase session pooler (IPv4) for Postgres connection
Direct DB host is IPv6-only for new Supabase projects and unreachable from
IPv4-only environments. The session pooler (Supavisor, port 5432) is the
correct entry point and keeps prepared statements working with EF Core.
2026-08-15 11:24:23 +00:00
EugeneTes
afe9ba1956 Add todos table migration 2026-08-15 11:23:40 +00:00
EugeneTes
d69e65a979 Add gitignore and README skeleton 2026-08-15 11:14:38 +00:00
45 changed files with 2722 additions and 22 deletions

28
.dockerignore Normal file
View File

@@ -0,0 +1,28 @@
**/bin/
**/obj/
**/node_modules/
**/dist/
**/.vite/
.git/
.gitignore
.vs/
.idea/
.vscode/
*.user
*.swp
**/appsettings.Development.json
**/appsettings.Local.json
.env
.env.local
frontend/.env
frontend/.env.local
Dockerfile
docker-compose.yml
.dockerignore
README.md
docs/
migrations/

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# .NET
bin/
obj/
*.user
*.suo
.vs/
# ASP.NET local secrets
backend/appsettings.Development.json
backend/appsettings.Local.json
# Node
node_modules/
dist/
.vite/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor / OS
.idea/
.vscode/
.DS_Store
# Frontend env
frontend/.env
frontend/.env.local
frontend/.env.*.local
deploy.json

194
CLAUDE.md Normal file
View File

@@ -0,0 +1,194 @@
# supabase_test — Supabase + .NET + React boilerplate
Reusable template for a per-user CRUD app backed by Supabase Auth + Postgres. The stack:
- **React** (Vite + TypeScript) SPA in the browser — uses `@supabase/supabase-js` only for authentication.
- **ASP.NET Core 9** Web API — validates Supabase-issued JWTs against the project's JWKS and owns all database access.
- **Supabase** — hosted auth + Postgres.
The .NET backend is the sole gateway to data. The browser never talks to PostgREST. Row-level security stays off; the backend enforces ownership in SQL.
## Architecture at a glance
```
[React SPA] ──(bearer JWT)──▶ [ASP.NET Core Web API] ──(Npgsql / EF Core)──▶ [Supabase Postgres]
│ ▲
└──(auth handshake with supabase-js)─┘
└────(JWKS fetch via OpenID discovery)─────┘
```
- React reads its session with `supabase.auth.getSession()` and attaches `Authorization: Bearer <access_token>` to every `/api/todos*` request.
- The .NET backend validates the JWT asymmetrically against `https://<ref>.supabase.co/auth/v1/.well-known/openid-configuration` — no shared secret. The `sub` claim (a UUID) becomes `user_id` on every query.
- Every mutation is scoped by `WHERE user_id = @currentUser`. Non-owned rows return 404 (not 403) — no existence leak.
- Backend connects to Postgres via Supabase's **session pooler** (`aws-<n>-<region>.pooler.supabase.com:5432`). The direct DB host is IPv6-only for new Supabase projects and unreachable from IPv4-only environments.
## Repo layout
```
backend/ ASP.NET Core 9 Web API
├── Program.cs DbContext, JWT auth, controllers, CORS (dev-only), SPA fallback, /health
├── Controllers/TodosController.cs GET / POST / PATCH / DELETE, [Authorize]-gated
├── Data/AppDbContext.cs EF Core context (single Todo entity)
├── Models/Todo.cs
├── Dtos/ Wire DTOs (never expose user_id)
├── appsettings.json Shared, non-secret defaults
├── appsettings.Development.example.json Template with placeholders
└── appsettings.Development.json REAL values, gitignored
frontend/ Vite + React + TypeScript SPA
├── src/lib/supabase.ts createClient(url, publishable_key)
├── src/lib/api.ts Authed fetch wrapper (attaches Bearer)
├── src/auth/AuthProvider.tsx Session state via onAuthStateChange
├── src/auth/SignIn.tsx Email + password sign-in / sign-up
├── src/todos/TodoList.tsx List + add + toggle + delete
├── src/App.tsx / main.tsx Route between SignIn and TodoList
├── .env.example Template with placeholders
└── .env.local REAL values, gitignored
migrations/001_create_todos.sql Plain SQL applied once against Supabase Postgres
docs/superpowers/specs/ Original design spec
docs/superpowers/plans/ Implementation plan
Dockerfile / docker-compose.yml Coolify-ready single-image packaging
.dockerignore Keeps host bin/, obj/, node_modules/ out of the build context
deploy.json Coolify per-app config (gitignored — holds runtime secrets)
```
## Configuring for a new Supabase project
**Six values** change per Supabase project. Everything else is project-agnostic — same schema, same auth flow, same code.
| Value | Where to get it (Supabase dashboard) | Where it goes |
|---|---|---|
| Project URL (e.g. `https://<ref>.supabase.co`) | Project Settings → API → Project URL | `backend/appsettings.Development.json` (`Supabase:MetadataAddress`, `Supabase:Issuer`) and `frontend/.env.local` (`VITE_SUPABASE_URL`) |
| Publishable (anon) key | Project Settings → API → Project API Keys → `publishable` | `frontend/.env.local` (`VITE_SUPABASE_PUBLISHABLE_KEY`) |
| Secret (service-role) key | Project Settings → API → Project API Keys → `secret` | NOT used by the app; only useful for admin-API E2E tests |
| Database password | Set once during project creation; can be reset | `backend/appsettings.Development.json` (part of `ConnectionStrings:Postgres`) |
| Session pooler hostname | Project Settings → Database → Connection string → **Session pooler** tab (port 5432) | `backend/appsettings.Development.json` (host + username in `ConnectionStrings:Postgres`) |
| Pooler username | Same tab; format is `postgres.<project-ref>` — always postgres + dot + project ref | Same connection string |
### Concrete steps for a new project
1. **Copy the templates and fill them in:**
```bash
cp backend/appsettings.Development.example.json backend/appsettings.Development.json
cp frontend/.env.example frontend/.env.local
```
Substitute the six values above.
2. **Apply the migration** against the new Supabase project:
```bash
PGPASSWORD='<db-password>' psql \
"host=aws-<n>-<region>.pooler.supabase.com port=5432 dbname=postgres user=postgres.<project-ref> sslmode=require" \
-f migrations/001_create_todos.sql
```
(Or paste the SQL into the Supabase SQL Editor.)
3. **Disable email confirmation for dev:** Supabase dashboard → Authentication → Providers → Email → toggle **Confirm email** OFF. Otherwise `supabase.auth.signUp` won't return a session and the SignIn UI hangs at "Working…" while a confirmation email is queued (and often silently rate-limited to 24/hour on the default SMTP).
4. **Install and run:**
```bash
cd frontend && npm install
# Terminal 1
cd backend && dotnet run # http://localhost:5057
# Terminal 2
cd frontend && npm run dev # http://localhost:5173
```
## Config surface — every knob
### Backend (`backend/appsettings.Development.json`)
```json
{
"ConnectionStrings": {
"Postgres": "Host=aws-<n>-<region>.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.<ref>;Password=<db-password>;SSL Mode=Require;Trust Server Certificate=true"
},
"Supabase": {
"MetadataAddress": "https://<ref>.supabase.co/auth/v1/.well-known/openid-configuration",
"Issuer": "https://<ref>.supabase.co/auth/v1",
"Audience": "authenticated"
}
}
```
`Audience` is always `authenticated` for user-issued JWTs — do not change unless you're validating service-role tokens (you shouldn't be, in this app).
The backend's port comes from `backend/Properties/launchSettings.json` (`applicationUrl` — default `http://localhost:5057`). If you move the backend to a different port, also update `VITE_API_URL` and the CORS `WithOrigins(...)` clause in `Program.cs`.
### Frontend (`frontend/.env.local`)
```
VITE_SUPABASE_URL=https://<ref>.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
VITE_API_URL=http://localhost:5057
```
`VITE_API_URL` empty (or unset) means **same-origin** — used when the backend serves the built SPA from `wwwroot/` in production (Dockerfile path). Set it explicitly during local dev when frontend and backend run on different ports.
### Container / Coolify (`docker-compose.yml`)
Six env vars flow in from Coolify's env-var UI:
| Coolify env | Purpose |
|---|---|
| `VITE_SUPABASE_URL` | Baked into the client bundle at image-build time (compose `build.args`) |
| `VITE_SUPABASE_PUBLISHABLE_KEY` | Same |
| `DB_CONNECTION_STRING` | Runtime, mapped to `ConnectionStrings__Postgres` |
| `SUPABASE_METADATA_ADDRESS` | Runtime, mapped to `Supabase__MetadataAddress` |
| `SUPABASE_ISSUER` | Runtime, mapped to `Supabase__Issuer` |
| — | `Supabase__Audience` is hard-coded to `authenticated` in the compose |
The compose file also declares `SERVICE_FQDN_APP_8080: /`. Coolify substitutes this with the app's real domain and injects the matching Traefik routing labels — **only if `docker_compose_domains` is set on the Coolify application**. Skipping that field turns every request into a 404 (see the `deploying-to-coolify-via-api` skill for the fix).
## Data model
Single table:
```sql
create table public.todos (
id bigserial primary key,
user_id uuid not null, -- Supabase auth.users.id (JWT sub claim)
title text not null check (length(title) between 1 and 500),
completed boolean not null default false,
created_at timestamptz not null default now()
);
create index todos_user_id_created_at_idx on public.todos (user_id, created_at desc);
```
No FK to `auth.users` — decouples from Supabase's internal schema and avoids permission headaches at migration time.
## Extending the pattern
To add a new entity (e.g. `notes`), the same shape repeats:
1. Add a table with `user_id uuid not null` + a `(user_id, created_at desc)` index. Ship as `migrations/00N_create_notes.sql`.
2. Add `backend/Models/Note.cs`, `backend/Dtos/{NoteDto,CreateNoteRequest,UpdateNoteRequest}.cs`.
3. Register the DbSet in `AppDbContext` with `HasIndex(...)` matching the SQL index name.
4. Add `NotesController` — copy `TodosController` verbatim, swap entity/DTO names. Keep the `CurrentUserId()` + `WHERE user_id == userId` pattern for every read/write; that's what enforces per-user isolation without RLS.
5. Frontend: an `api.notes` object with `list/create/setX/remove` alongside the existing `api` todos block, plus a `NotesList` component modelled on `TodoList`.
The critical invariant, everywhere: **every query filters by `userId = CurrentUserId()`**. Missing that on a single endpoint leaks other users' data.
## Known gotchas (all bit us during initial build — documented so they don't bite you)
| Symptom | Cause | Fix |
|---|---|---|
| `connection to server at "db.<ref>.supabase.co" failed: Network is unreachable` | Direct DB host is IPv6-only on new Supabase projects; sandbox has no IPv6 egress | Use the session pooler (`aws-<n>-<region>.pooler.supabase.com`, port 5432, user `postgres.<ref>`) |
| `dotnet add package X` restore fails with `NU1202` | CLI grabbed a newer major version incompatible with `net9.0` | Pin: `dotnet add package X --version 9.*` |
| Sign-up hangs / "email rate limit exceeded" | Email confirmation ON; Supabase default SMTP limits to 24/hour | Toggle Auth → Providers → Email → **Confirm email** OFF for dev; or wire real SMTP |
| Sign-up rejects `@example.com` with `email_address_invalid` | Supabase blocklists disposable domains on the ordinary signup endpoint | For E2E scripts, use the admin API (`POST /auth/v1/admin/users` with `email_confirm:true`) — see `docs/superpowers/plans/2026-08-15-supabase-todo-app.md` Task 8 |
| Deploy on Coolify finishes green, live URL returns `404 page not found` | Missing / mis-shaped `docker_compose_domains` on the Coolify app | See the `deploying-to-coolify-via-api` skill: PATCH the array-form after the first deploy loads the compose file |
| Vite template's `index.css` styles fight your inline styles | Scaffolded `index.css` ships opinionated marketing styles (large h1, fixed `#root` width) | Replace with the minimal reset in `frontend/src/index.css` |
| Backend port 5000 collisions across dev sessions | Common default for other Linux services / stale processes | This project uses `5057` — check the same for any port you pick |
## What NOT to change without thinking
- **RLS off** is a deliberate choice. If you enable RLS on `public.todos`, you must also either (a) write `USING (auth.uid() = user_id)` policies, or (b) have the backend run as a role with `BYPASSRLS`. Otherwise the backend's own queries stop returning rows.
- The **DTO response shape** deliberately omits `user_id`. Adding it back exposes the caller's own id (harmless) but invites confusion.
- **`RequireHttpsMetadata = true`** on the JwtBearer options. If you're testing against a non-HTTPS Supabase project (there is no such thing in practice), set to false — otherwise leave it. This prevents downgrade attacks on the JWKS fetch.
- **`MapInboundClaims = false`** — keeps the raw `sub` claim instead of remapping to `ClaimTypes.NameIdentifier`. If you flip this, update the `CurrentUserId()` helper.
## Related skills / docs
- `docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md` — the original design decisions and out-of-scope list.
- `docs/superpowers/plans/2026-08-15-supabase-todo-app.md` — the 17-task implementation plan, including manual E2E verification recipes for backend (curl) and frontend (Playwright).
- Global skill: `creating-supabase-app-from-boilerplate` — clones this repo and reconfigures it against a new Supabase project.
- Global skill: `preparing-dotnet-react-app-for-coolify` — the Dockerfile + compose pattern this repo already implements.
- Global skill: `deploying-to-coolify-via-api` — how the Coolify project + application were created; run again against a new project or copy `deploy.json` and change the values.

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.7
# 1. Frontend build
FROM node:20-alpine AS web
WORKDIR /src
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
# Public values baked into the client bundle at build time.
# API URL is empty in production (same-origin — the backend serves the SPA).
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_PUBLISHABLE_KEY
ARG VITE_API_URL=""
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL \
VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY \
VITE_API_URL=$VITE_API_URL
RUN npm run build
# 2. Backend publish
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS api
WORKDIR /src
COPY backend/backend.csproj backend/
RUN dotnet restore backend/backend.csproj
COPY backend/ backend/
RUN dotnet publish backend/backend.csproj \
-c Release -o /out --no-restore /p:UseAppHost=false
# 3. Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=api /out ./
COPY --from=web /src/dist ./wwwroot
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["dotnet", "backend.dll"]

View File

@@ -1,2 +1,65 @@
# SupabaseTest
# supabase_test
Minimal per-user to-do list.
- **Frontend:** React 19 + TypeScript, built with Vite. Uses `@supabase/supabase-js` only for authentication.
- **Backend:** ASP.NET Core 9 Web API. Validates Supabase-issued JWTs against the project's JWKS (via OpenID discovery), talks to Postgres directly with EF Core + Npgsql.
- **Auth + DB:** Supabase.
See [the design doc](docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md) for the architecture and rationale, and [the implementation plan](docs/superpowers/plans/2026-08-15-supabase-todo-app.md) for how it was built.
## Prerequisites
- .NET SDK 9.0
- Node.js 20+
- A Supabase project (URL, publishable key, Postgres password, and session-pooler hostname)
## One-time setup
1. **Apply the database migration to your Supabase project.** Either:
- Open the Supabase dashboard → SQL Editor → paste `migrations/001_create_todos.sql` → Run, or
- `PGPASSWORD='<db-password>' psql "host=aws-<n>-<region>.pooler.supabase.com port=5432 dbname=postgres user=postgres.<project-ref> sslmode=require" -f migrations/001_create_todos.sql`
2. **Disable email confirmation for dev.** Supabase dashboard → Authentication → Providers → Email → toggle **Confirm email** off, so sign-up returns a session immediately.
3. **Backend config:**
```bash
cp backend/appsettings.Development.example.json backend/appsettings.Development.json
# Fill in: the pooler host/region, your project ref, and the DB password.
```
The connection string uses Supabase's **session pooler** (port 5432, IPv4). The direct host `db.<ref>.supabase.co` is IPv6-only on new projects and won't reach from IPv4-only environments. Grab the exact URL from Supabase dashboard → Project Settings → Database → Connection string → "Session pooler" tab.
4. **Frontend config:**
```bash
cp frontend/.env.example frontend/.env.local
# Fill in VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_API_URL.
```
5. **Install frontend deps:**
```bash
cd frontend && npm install
```
## Run it
Two terminals:
```bash
# Terminal 1 — backend on http://localhost:5057
cd backend && dotnet run
# Terminal 2 — Vite dev server on http://localhost:5173
cd frontend && npm run dev
```
Open <http://localhost:5173>, sign up, add todos.
## Layout
```
backend/ ASP.NET Core Web API
frontend/ Vite + React + TypeScript SPA
migrations/ Plain SQL files applied to Supabase Postgres
docs/ Design and implementation-plan docs
```
## Notes
- Row Level Security is deliberately off on the `todos` table — the .NET API is the only writer and enforces ownership via `WHERE user_id = @currentUser` in every query. If you ever want the browser to talk to PostgREST directly, turn RLS on and write policies first.
- No automated tests in this first pass. `docs/superpowers/plans/…` lists manual verification steps used during construction (curl for backend, browser for frontend).

View File

@@ -0,0 +1,77 @@
using System.Security.Claims;
using Backend.Data;
using Backend.Dtos;
using Backend.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Backend.Controllers;
[ApiController]
[Route("api/todos")]
[Authorize]
public class TodosController(AppDbContext db) : ControllerBase
{
private Guid CurrentUserId()
{
var sub = User.FindFirstValue("sub")
?? throw new InvalidOperationException("Authenticated request missing 'sub' claim");
return Guid.Parse(sub);
}
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoDto>>> List()
{
var userId = CurrentUserId();
var todos = await db.Todos
.Where(t => t.UserId == userId)
.OrderByDescending(t => t.CreatedAt)
.Select(t => new TodoDto(t.Id, t.Title, t.Completed, t.CreatedAt))
.ToListAsync();
return Ok(todos);
}
[HttpPost]
public async Task<ActionResult<TodoDto>> Create([FromBody] CreateTodoRequest body)
{
if (!ModelState.IsValid) return ValidationProblem(ModelState);
var userId = CurrentUserId();
var todo = new Todo
{
UserId = userId,
Title = body.Title,
Completed = false,
CreatedAt = DateTimeOffset.UtcNow
};
db.Todos.Add(todo);
await db.SaveChangesAsync();
var dto = new TodoDto(todo.Id, todo.Title, todo.Completed, todo.CreatedAt);
return CreatedAtAction(nameof(List), new { id = todo.Id }, dto);
}
[HttpPatch("{id:long}")]
public async Task<ActionResult<TodoDto>> Update(long id, [FromBody] UpdateTodoRequest body)
{
var userId = CurrentUserId();
var todo = await db.Todos.FirstOrDefaultAsync(t => t.Id == id && t.UserId == userId);
if (todo is null) return NotFound();
if (body.Completed.HasValue) todo.Completed = body.Completed.Value;
await db.SaveChangesAsync();
return Ok(new TodoDto(todo.Id, todo.Title, todo.Completed, todo.CreatedAt));
}
[HttpDelete("{id:long}")]
public async Task<IActionResult> Delete(long id)
{
var userId = CurrentUserId();
var affected = await db.Todos
.Where(t => t.Id == id && t.UserId == userId)
.ExecuteDeleteAsync();
return affected == 0 ? NotFound() : NoContent();
}
}

View File

@@ -0,0 +1,25 @@
using Backend.Models;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Todo>(e =>
{
e.ToTable("todos", schema: "public");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").ValueGeneratedOnAdd();
e.Property(x => x.UserId).HasColumnName("user_id").IsRequired();
e.Property(x => x.Title).HasColumnName("title").IsRequired().HasMaxLength(500);
e.Property(x => x.Completed).HasColumnName("completed").IsRequired();
e.Property(x => x.CreatedAt).HasColumnName("created_at").IsRequired();
e.HasIndex(x => new { x.UserId, x.CreatedAt })
.HasDatabaseName("todos_user_id_created_at_idx");
});
}
}

View File

@@ -0,0 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace Backend.Dtos;
public record CreateTodoRequest([Required, StringLength(500, MinimumLength = 1)] string Title);

3
backend/Dtos/TodoDto.cs Normal file
View File

@@ -0,0 +1,3 @@
namespace Backend.Dtos;
public record TodoDto(long Id, string Title, bool Completed, DateTimeOffset CreatedAt);

View File

@@ -0,0 +1,3 @@
namespace Backend.Dtos;
public record UpdateTodoRequest(bool? Completed);

10
backend/Models/Todo.cs Normal file
View File

@@ -0,0 +1,10 @@
namespace Backend.Models;
public class Todo
{
public long Id { get; set; }
public Guid UserId { get; set; }
public string Title { get; set; } = string.Empty;
public bool Completed { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}

62
backend/Program.cs Normal file
View File

@@ -0,0 +1,62 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Postgres")));
const string DevCorsPolicy = "DevCors";
builder.Services.AddCors(options =>
{
options.AddPolicy(DevCorsPolicy, policy => policy
.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.WithMethods("GET", "POST", "PATCH", "DELETE"));
});
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MetadataAddress = builder.Configuration["Supabase:MetadataAddress"]
?? throw new InvalidOperationException("Supabase:MetadataAddress not configured");
options.RequireHttpsMetadata = true;
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Supabase:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Supabase:Audience"],
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = "sub"
};
});
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseCors(DevCorsPolicy);
}
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapFallbackToFile("index.html");
app.Run();

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"backend": {
"commandName": "Project",
"launchBrowser": false,
"applicationUrl": "http://localhost:5057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"ConnectionStrings": {
"Postgres": "Host=aws-N-REGION.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.YOURPROJECTREF;Password=REPLACE_ME;SSL Mode=Require;Trust Server Certificate=true"
},
"Supabase": {
"MetadataAddress": "https://YOURPROJECTREF.supabase.co/auth/v1/.well-known/openid-configuration",
"Issuer": "https://YOURPROJECTREF.supabase.co/auth/v1",
"Audience": "authenticated"
}
}

6
backend/appsettings.json Normal file
View File

@@ -0,0 +1,6 @@
{
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"AllowedHosts": "*"
}

16
backend/backend.csproj Normal file
View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.*" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.19" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.19" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
</Project>

6
backend/backend.http Normal file
View File

@@ -0,0 +1,6 @@
@backend_HostAddress = http://localhost:5211
GET {{backend_HostAddress}}/weatherforecast/
Accept: application/json
###

25
docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
VITE_SUPABASE_URL: ${VITE_SUPABASE_URL}
VITE_SUPABASE_PUBLISHABLE_KEY: ${VITE_SUPABASE_PUBLISHABLE_KEY}
VITE_API_URL: ""
expose:
- "8080"
environment:
SERVICE_FQDN_APP_8080: /
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: "http://+:8080"
ConnectionStrings__Postgres: ${DB_CONNECTION_STRING}
Supabase__MetadataAddress: ${SUPABASE_METADATA_ADDRESS}
Supabase__Issuer: ${SUPABASE_ISSUER}
Supabase__Audience: authenticated
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s

View File

@@ -157,7 +157,7 @@ From the repo root:
dotnet new webapi --name backend --framework net9.0 --no-https --use-controllers --output backend
```
`--no-https` keeps local dev on plain `http://localhost:5000` (matches CORS config in Task 7). `--use-controllers` gives us `Controllers/`-based routing instead of minimal APIs.
`--no-https` keeps local dev on plain `http://localhost:5057` (matches CORS config in Task 7). `--use-controllers` gives us `Controllers/`-based routing instead of minimal APIs.
Delete the sample files the template generates:
@@ -187,7 +187,7 @@ Replace `backend/Properties/launchSettings.json` with:
"backend": {
"commandName": "Project",
"launchBrowser": false,
"applicationUrl": "http://localhost:5000",
"applicationUrl": "http://localhost:5057",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -198,6 +198,14 @@ Replace `backend/Properties/launchSettings.json` with:
- [ ] **Step 4: Create `appsettings.Development.example.json` (committed template)**
The connection string uses Supabase's **session pooler** (Supavisor), not the direct DB host. Rationale: the direct host (`db.<ref>.supabase.co`) is IPv6-only for new Supabase projects; the pooler is IPv4-reachable and is Supabase's recommended entry point. Session mode (port `5432`) keeps prepared statements working with EF Core; transaction mode (`6543`) would break them.
Pooler URL shape:
```
Host=aws-<n>-<region>.pooler.supabase.com;Port=5432;Username=postgres.<project-ref>;Password=…
```
Get the exact `n` and `region` from your Supabase dashboard → Project Settings → Database → Connection string → "Session pooler" tab.
Create `backend/appsettings.Development.example.json`:
```json
@@ -206,11 +214,11 @@ Create `backend/appsettings.Development.example.json`:
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"ConnectionStrings": {
"Postgres": "Host=db.YOURPROJECT.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=REPLACE_ME;SSL Mode=Require;Trust Server Certificate=true"
"Postgres": "Host=aws-N-REGION.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.YOURPROJECTREF;Password=REPLACE_ME;SSL Mode=Require;Trust Server Certificate=true"
},
"Supabase": {
"MetadataAddress": "https://YOURPROJECT.supabase.co/auth/v1/.well-known/openid-configuration",
"Issuer": "https://YOURPROJECT.supabase.co/auth/v1",
"MetadataAddress": "https://YOURPROJECTREF.supabase.co/auth/v1/.well-known/openid-configuration",
"Issuer": "https://YOURPROJECTREF.supabase.co/auth/v1",
"Audience": "authenticated"
}
}
@@ -218,7 +226,7 @@ Create `backend/appsettings.Development.example.json`:
- [ ] **Step 5: Create `appsettings.Development.json` (gitignored, real values)**
Create `backend/appsettings.Development.json`. Substitute the DB password provided out of band (the credentials were given in the brainstorming session — the executor has them). Do **not** paste the DB password into any committed file:
Create `backend/appsettings.Development.json`. Substitute the DB password provided out of band. Do **not** paste the DB password into any committed file:
```json
{
@@ -226,7 +234,7 @@ Create `backend/appsettings.Development.json`. Substitute the DB password provid
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"ConnectionStrings": {
"Postgres": "Host=db.jrbqfctqhjttxobtoqts.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=REPLACE_WITH_DB_PASSWORD;SSL Mode=Require;Trust Server Certificate=true"
"Postgres": "Host=aws-1-eu-west-1.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.jrbqfctqhjttxobtoqts;Password=REPLACE_WITH_DB_PASSWORD;SSL Mode=Require;Trust Server Certificate=true"
},
"Supabase": {
"MetadataAddress": "https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1/.well-known/openid-configuration",
@@ -256,7 +264,7 @@ cd backend
dotnet build
dotnet run &
sleep 3
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/openapi/v1.json # 200 if webapi template's OpenAPI is on
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5057/openapi/v1.json # 200 if webapi template's OpenAPI is on
kill %1
```
@@ -285,11 +293,11 @@ Confirm `git status` does NOT list `backend/appsettings.Development.json`.
```bash
cd backend
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore --version 9.*
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 9.*
```
No `--version` — the CLI resolves the latest stable compatible with the SDK (9.x on this project).
`--version 9.*` pins to the 9.x major line. Without it, `dotnet add package` picks the absolute-latest stable (e.g. 10.x once released) which will not resolve against `net9.0``NU1202` error.
- [ ] **Step 2: Create the `Todo` entity**
@@ -401,9 +409,11 @@ git commit -m "Add EF Core, Npgsql, Todo entity, and AppDbContext"
```bash
cd backend
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 9.*
```
Same reason as Task 4: pin the major to avoid picking a version incompatible with `net9.0`.
- [ ] **Step 2: Wire authentication in `Program.cs`**
Open `backend/Program.cs`. Add these two `using` directives at the top (below the existing `using` lines):
@@ -675,7 +685,7 @@ sleep 5
- [ ] **Step 2: Confirm unauthenticated requests are rejected**
```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/api/todos
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5057/api/todos
```
Expected: `401`.
@@ -706,7 +716,7 @@ If `TOKEN` length is under 100 chars, sign-up did NOT return a session — email
- [ ] **Step 4: GET (empty list)**
```bash
curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/todos
curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" http://localhost:5057/api/todos
```
Expected: `[]` then `HTTP 200`.
@@ -714,7 +724,7 @@ Expected: `[]` then `HTTP 200`.
- [ ] **Step 5: POST (create)**
```bash
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:5000/api/todos \
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:5057/api/todos \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"buy milk"}'
```
@@ -725,7 +735,7 @@ Expected: a JSON body with `id`, `title:"buy milk"`, `completed:false`, `created
```bash
TID=<id from previous step>
curl -s -w "\nHTTP %{http_code}\n" -X PATCH "http://localhost:5000/api/todos/$TID" \
curl -s -w "\nHTTP %{http_code}\n" -X PATCH "http://localhost:5057/api/todos/$TID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"completed":true}'
```
@@ -735,7 +745,7 @@ Expected: JSON with `completed:true`, `HTTP 200`.
- [ ] **Step 7: DELETE**
```bash
curl -s -w "\nHTTP %{http_code}\n" -X DELETE "http://localhost:5000/api/todos/$TID" \
curl -s -w "\nHTTP %{http_code}\n" -X DELETE "http://localhost:5057/api/todos/$TID" \
-H "Authorization: Bearer $TOKEN"
```
@@ -819,7 +829,7 @@ Create `frontend/.env.example`:
```
VITE_SUPABASE_URL=https://YOURPROJECT.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOURKEY
VITE_API_URL=http://localhost:5000
VITE_API_URL=http://localhost:5057
```
- [ ] **Step 2: Create `frontend/.env.local` with real values**
@@ -827,7 +837,7 @@ VITE_API_URL=http://localhost:5000
```
VITE_SUPABASE_URL=https://jrbqfctqhjttxobtoqts.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_QajbbpfedzxWEhCPBKTZqg_M-8qyCUy
VITE_API_URL=http://localhost:5000
VITE_API_URL=http://localhost:5057
```
- [ ] **Step 3: Create the Supabase client module**
@@ -1341,7 +1351,7 @@ cd backend && dotnet run
cd frontend && npm run dev
```
Expected: backend on `http://localhost:5000`, Vite on `http://localhost:5173`.
Expected: backend on `http://localhost:5057`, Vite on `http://localhost:5173`.
- [ ] **Step 2: Sign up**
@@ -1440,7 +1450,7 @@ Two terminals:
```bash
# Terminal 1
cd backend && dotnet run # http://localhost:5000
cd backend && dotnet run # http://localhost:5057
# Terminal 2
cd frontend && npm run dev # http://localhost:5173

3
frontend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
VITE_SUPABASE_URL=https://YOURPROJECT.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOURKEY
VITE_API_URL=http://localhost:5057

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
frontend/README.md Normal file
View File

@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1373
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.109.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
frontend/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

11
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { useAuth } from './auth/AuthProvider';
import { SignIn } from './auth/SignIn';
import { TodoList } from './todos/TodoList';
export function App() {
const { session, loading } = useAuth();
if (loading) return <p style={{ textAlign: 'center', marginTop: '4rem' }}>Loading</p>;
if (!session) return <SignIn />;
return <TodoList userEmail={session.user.email ?? '(no email)'} />;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,31 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import type { Session } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
type AuthContextValue = {
session: Session | null;
loading: boolean;
};
const AuthContext = createContext<AuthContextValue>({ session: null, loading: true });
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
setLoading(false);
});
return () => {
data.subscription.unsubscribe();
};
}, []);
return <AuthContext.Provider value={{ session, loading }}>{children}</AuthContext.Provider>;
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -0,0 +1,70 @@
import { useState, type FormEvent } from 'react';
import { supabase } from '../lib/supabase';
type Mode = 'signIn' | 'signUp';
export function SignIn() {
const [mode, setMode] = useState<Mode>('signIn');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
const { error } =
mode === 'signIn'
? await supabase.auth.signInWithPassword({ email, password })
: await supabase.auth.signUp({ email, password });
if (error) setError(error.message);
// On success, AuthProvider's onAuthStateChange updates session and the UI switches.
} finally {
setBusy(false);
}
}
return (
<div style={{ maxWidth: 320, margin: '4rem auto', fontFamily: 'sans-serif' }}>
<h1>{mode === 'signIn' ? 'Sign in' : 'Sign up'}</h1>
<form onSubmit={onSubmit}>
<label style={{ display: 'block', marginBottom: 8 }}>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ width: '100%', padding: 6 }}
/>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
style={{ width: '100%', padding: 6 }}
/>
</label>
<button type="submit" disabled={busy} style={{ width: '100%', padding: 8 }}>
{busy ? 'Working…' : mode === 'signIn' ? 'Sign in' : 'Sign up'}
</button>
</form>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
<p>
<button
type="button"
onClick={() => setMode(mode === 'signIn' ? 'signUp' : 'signIn')}
style={{ background: 'none', border: 'none', color: '#06f', cursor: 'pointer' }}
>
{mode === 'signIn' ? "Don't have an account? Sign up" : 'Have an account? Sign in'}
</button>
</p>
</div>
);
}

9
frontend/src/index.css Normal file
View File

@@ -0,0 +1,9 @@
:root {
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
color-scheme: light dark;
}
body {
margin: 0;
}

55
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,55 @@
import { supabase } from './supabase';
const API_URL = import.meta.env.VITE_API_URL ?? '';
export type Todo = {
id: number;
title: string;
completed: boolean;
createdAt: string;
};
async function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
if (!token) throw new Error('Not signed in');
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${token}`);
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
return fetch(`${API_URL}${path}`, { ...init, headers });
}
async function assertOk(res: Response): Promise<Response> {
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`API ${res.status}: ${text || res.statusText}`);
}
return res;
}
export const api = {
async list(): Promise<Todo[]> {
const res = await assertOk(await authedFetch('/api/todos'));
return res.json();
},
async create(title: string): Promise<Todo> {
const res = await assertOk(
await authedFetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) })
);
return res.json();
},
async setCompleted(id: number, completed: boolean): Promise<Todo> {
const res = await assertOk(
await authedFetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed })
})
);
return res.json();
},
async remove(id: number): Promise<void> {
await assertOk(await authedFetch(`/api/todos/${id}`, { method: 'DELETE' }));
}
};

View File

@@ -0,0 +1,12 @@
import { createClient } from '@supabase/supabase-js';
const url = import.meta.env.VITE_SUPABASE_URL;
const publishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!url || !publishableKey) {
throw new Error(
'Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY. Copy .env.example to .env.local and fill it in.'
);
}
export const supabase = createClient(url, publishableKey);

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { AuthProvider } from './auth/AuthProvider';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>
);

View File

@@ -0,0 +1,105 @@
import { useEffect, useState, type FormEvent } from 'react';
import { api, type Todo } from '../lib/api';
import { supabase } from '../lib/supabase';
export function TodoList({ userEmail }: { userEmail: string }) {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTitle, setNewTitle] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
async function refresh() {
try {
setTodos(await api.list());
} catch (e) {
setError(String(e));
}
}
useEffect(() => {
refresh().finally(() => setLoading(false));
}, []);
async function onAdd(e: FormEvent) {
e.preventDefault();
const title = newTitle.trim();
if (!title) return;
setError(null);
try {
const created = await api.create(title);
setTodos((prev) => [created, ...prev]);
setNewTitle('');
} catch (e) {
setError(String(e));
}
}
async function onToggle(todo: Todo) {
setError(null);
try {
const updated = await api.setCompleted(todo.id, !todo.completed);
setTodos((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
} catch (e) {
setError(String(e));
}
}
async function onDelete(todo: Todo) {
setError(null);
try {
await api.remove(todo.id);
setTodos((prev) => prev.filter((t) => t.id !== todo.id));
} catch (e) {
setError(String(e));
}
}
async function onSignOut() {
await supabase.auth.signOut();
}
return (
<div style={{ maxWidth: 520, margin: '2rem auto', fontFamily: 'sans-serif' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h1>Todos</h1>
<span>
{userEmail}{' '}
<button type="button" onClick={onSignOut}>Sign out</button>
</span>
</header>
<form onSubmit={onAdd} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="What needs doing?"
maxLength={500}
style={{ flex: 1, padding: 6 }}
/>
<button type="submit">Add</button>
</form>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
{loading ? (
<p>Loading</p>
) : todos.length === 0 ? (
<p>No todos yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((t) => (
<li
key={t.id}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}
>
<input type="checkbox" checked={t.completed} onChange={() => onToggle(t)} />
<span style={{ flex: 1, textDecoration: t.completed ? 'line-through' : 'none' }}>
{t.title}
</span>
<button type="button" onClick={() => onDelete(t)}>Delete</button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View File

@@ -0,0 +1,10 @@
create table if not exists public.todos (
id bigserial primary key,
user_id uuid not null,
title text not null check (length(title) between 1 and 500),
completed boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists todos_user_id_created_at_idx
on public.todos (user_id, created_at desc);