Compare commits

...

19 Commits

Author SHA1 Message Date
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
41 changed files with 2427 additions and 22 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# .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

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; }
}

57
backend/Program.cs Normal file
View File

@@ -0,0 +1,57 @@
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.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
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
###

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;
}

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

@@ -0,0 +1,56 @@
import { supabase } from './supabase';
const API_URL = import.meta.env.VITE_API_URL;
if (!API_URL) throw new Error('Missing 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);