create-fff-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +21 -0
  2. package/src/index.ts +119 -0
  3. package/template/.config/dotnet-tools.json +5 -0
  4. package/template/dev-server.ts +415 -0
  5. package/template/index.html +23 -0
  6. package/template/migrate.ts +166 -0
  7. package/template/migrations/0001_init.sql +25 -0
  8. package/template/migrations/0002_employees.sql +16 -0
  9. package/template/package.json +29 -0
  10. package/template/public/app.js +156 -0
  11. package/template/public/runtime.js +149 -0
  12. package/template/public/style.css +769 -0
  13. package/template/schema.sql +25 -0
  14. package/template/src/client/App.fs +55 -0
  15. package/template/src/client/Client.fsproj +24 -0
  16. package/template/src/client/Nav.fs +75 -0
  17. package/template/src/server/Domain/Employees/Employee.fs +34 -0
  18. package/template/src/server/Endpoints/Employees/Routes.fs +35 -0
  19. package/template/src/server/Endpoints/Pages/About.fjsx +66 -0
  20. package/template/src/server/Endpoints/Pages/EmployeeEdit.fjsx +66 -0
  21. package/template/src/server/Endpoints/Pages/EmployeeList.fjsx +54 -0
  22. package/template/src/server/Endpoints/Pages/EmployeeNew.fjsx +55 -0
  23. package/template/src/server/Endpoints/Pages/Layout.fjsx +40 -0
  24. package/template/src/server/Endpoints/Pages/Routes.fs +117 -0
  25. package/template/src/server/Endpoints/Pages/Templates.fs +45 -0
  26. package/template/src/server/Server.fsproj +28 -0
  27. package/template/src/server/Worker.fs +45 -0
  28. package/template/src/server/Workflows/Employees/EmployeeWorkflows.fs +86 -0
  29. package/template/webpack.client.js +18 -0
  30. package/template/wrangler.toml +23 -0
@@ -0,0 +1,25 @@
1
+ -- fff-stack starter schema
2
+ -- Deploy with: wrangler d1 execute fff-stack-db --file=schema.sql
3
+
4
+ CREATE TABLE IF NOT EXISTS users (
5
+ id TEXT PRIMARY KEY,
6
+ name TEXT NOT NULL,
7
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
8
+ );
9
+
10
+ CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at);
11
+
12
+ CREATE TABLE IF NOT EXISTS posts (
13
+ id TEXT PRIMARY KEY,
14
+ author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
15
+ title TEXT NOT NULL,
16
+ slug TEXT NOT NULL UNIQUE,
17
+ body TEXT NOT NULL,
18
+ published_at TEXT,
19
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
20
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
21
+ );
22
+
23
+ CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
24
+ CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
25
+ CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
@@ -0,0 +1,55 @@
1
+ module App.Client.App
2
+
3
+ open FffStack.Client
4
+ open Fable.Core
5
+ open Browser
6
+ open Browser.Types
7
+
8
+ // ── Island registry ────────────────────────────────────────────────────────────
9
+
10
+ let private registry = System.Collections.Generic.Dictionary<string, Component>()
11
+
12
+ /// Returns the first mounted instance of the named island, if any.
13
+ let findIsland<'T when 'T :> Component> (name: string) : 'T option =
14
+ match registry.TryGetValue(name) with
15
+ | true, c -> c :?> 'T |> Some
16
+ | _ -> None
17
+
18
+ // ── Island mounting ────────────────────────────────────────────────────────────
19
+
20
+ let private ensureId (el: HTMLElement) (name: string) : string =
21
+ if el.id <> "" then el.id
22
+ else
23
+ let id = sprintf "island-%s" (name.ToLower())
24
+ el.id <- id
25
+ id
26
+
27
+ let private mountOne (el: HTMLElement) : unit =
28
+ let name = el?dataset?island |> string
29
+ let id = ensureId el name
30
+ // Add islands here when needed:
31
+ // | "MyComponent" -> Some (upcast MyComponent(id))
32
+ let instance : Component option =
33
+ match name with
34
+ | unknown ->
35
+ console.warn (sprintf "[fff-stack] Unknown island: %s" unknown)
36
+ None
37
+ instance |> Option.iter (fun comp ->
38
+ if int el.children.length > 0 then comp.Hydrate()
39
+ else comp.Mount()
40
+ if not (registry.ContainsKey(name)) then
41
+ registry.[name] <- comp)
42
+
43
+ /// Mount every [data-island] element in the current page.
44
+ let mountIslands () =
45
+ let nodes = document.querySelectorAll "[data-island]"
46
+ for i in 0 .. int nodes.length - 1 do
47
+ mountOne (nodes.item(float i) :?> HTMLElement)
48
+
49
+ // ── Entry point ────────────────────────────────────────────────────────────────
50
+
51
+ let init () =
52
+ mountIslands ()
53
+ Nav.init mountIslands // client-side navigation; re-mounts islands after page swap
54
+
55
+ do init ()
@@ -0,0 +1,24 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+
3
+ <PropertyGroup>
4
+ <TargetFramework>net9.0</TargetFramework>
5
+ <RootNamespace>App.Client</RootNamespace>
6
+ </PropertyGroup>
7
+
8
+ <ItemGroup>
9
+ <Compile Include="Nav.fs" />
10
+ <Compile Include="App.fs" />
11
+ </ItemGroup>
12
+
13
+ <ItemGroup>
14
+ <PackageReference Include="Fable.Promise" Version="3.*" />
15
+ <PackageReference Include="Fable.Fetch" Version="2.*" />
16
+ <PackageReference Include="Fable.Browser.Dom" Version="2.*" />
17
+ <PackageReference Include="Fable.Browser.Event" Version="1.*" />
18
+ </ItemGroup>
19
+
20
+ <ItemGroup>
21
+ <PackageReference Include="FffStack.Client" Version="0.1.0" />
22
+ </ItemGroup>
23
+
24
+ </Project>
@@ -0,0 +1,75 @@
1
+ module App.Client.Nav
2
+
3
+ open Fable.Core
4
+ open Fable.Core.JsInterop
5
+ open Browser
6
+ open Browser.Types
7
+
8
+ // ── Client-side navigation ────────────────────────────────────────────────────
9
+ // Intercepts same-origin link clicks, fetches the target page with fetch(),
10
+ // swaps <main> content, and updates history — no full page reload.
11
+ // Falls back to normal navigation for external links, downloads, or errors.
12
+
13
+ let private mainSelector = "main"
14
+ let private navSelector = ".site-nav"
15
+
16
+ let mutable private onNavigated : (unit -> unit) = ignore
17
+
18
+ /// Update the active state of navigation links based on the new pathname.
19
+ let private updateNav (pathname: string) =
20
+ let nodes = document.querySelectorAll (sprintf "%s a" navSelector)
21
+ for i in 0 .. int nodes.length - 1 do
22
+ let a = nodes.item(float i) :?> HTMLAnchorElement
23
+ let href = a.getAttribute "href"
24
+ if href = pathname then a.classList.add "active"
25
+ else a.classList.remove "active"
26
+
27
+ /// Fetch a page and swap just its <main> content into the current page.
28
+ let private navigate (url: string) : JS.Promise<unit> =
29
+ promise {
30
+ let! resp = Fetch.fetch url []
31
+ let ok : bool = resp?ok
32
+ if not ok then
33
+ window.location.href <- url
34
+ else
35
+ let! htmlObj = resp?text()
36
+ let html = string htmlObj
37
+ let parser : obj = emitJsExpr () "new DOMParser()"
38
+ let doc : obj = emitJsExpr (parser, html) "$0.parseFromString($1, 'text/html')"
39
+ let newMain : obj = doc?querySelector(mainSelector)
40
+ let curMain : obj = document?querySelector(mainSelector)
41
+ if not (isNull newMain) && not (isNull curMain) then
42
+ curMain?innerHTML <- newMain?innerHTML
43
+ let uri : obj = emitJsExpr (url, window.location.href) "new URL($0, $1)"
44
+ let pathname : string = uri?pathname
45
+ window.history.pushState(null, "", pathname)
46
+ updateNav pathname
47
+ onNavigated()
48
+ else
49
+ window.location.href <- url
50
+ }
51
+
52
+ /// Intercept clicks on same-origin links.
53
+ let private onClick (e: Event) =
54
+ let el = e.target :?> HTMLElement
55
+ match el.closest "a" with
56
+ | None -> ()
57
+ | Some a ->
58
+ let anchor = a :?> HTMLAnchorElement
59
+ let isSameOrigin : bool = anchor?origin |> string = window.location.origin
60
+ let isPlainClick = not (e?metaKey || e?ctrlKey || e?shiftKey || e?altKey)
61
+ let hasNoTarget = anchor.target = "" || anchor.target = "_self"
62
+ let hasNoDownload = anchor.getAttribute "download" |> isNull
63
+ if isSameOrigin && isPlainClick && hasNoTarget && hasNoDownload then
64
+ e.preventDefault()
65
+ navigate anchor.href |> ignore
66
+
67
+ /// Handle the browser back/forward buttons.
68
+ let private onPopState (_e: Event) =
69
+ navigate window.location.href |> ignore
70
+
71
+ /// Initialise client-side navigation. Pass mountIslands as callback to re-mount islands after navigation.
72
+ let init (mountFn: unit -> unit) =
73
+ onNavigated <- mountFn
74
+ document.addEventListener ("click", onClick)
75
+ window.addEventListener ("popstate", onPopState)
@@ -0,0 +1,34 @@
1
+ namespace App.Server.Domain.Employees
2
+
3
+ // ── Value objects ──────────────────────────────────────────────────────────────
4
+
5
+ type EmployeeName = private EmployeeName of string
6
+
7
+ module EmployeeName =
8
+ let create (raw: string) =
9
+ let t = raw.Trim()
10
+ if t.Length < 2 then Error "Name must be at least 2 characters"
11
+ elif t.Length > 100 then Error "Name must be at most 100 characters"
12
+ else Ok (EmployeeName t)
13
+ let value (EmployeeName n) = n
14
+
15
+ type WorkEmail = private WorkEmail of string
16
+
17
+ module WorkEmail =
18
+ let create (raw: string) =
19
+ let t = raw.Trim().ToLowerInvariant()
20
+ if not (t.Contains "@") || t.Length < 5 then Error "Invalid email address"
21
+ else Ok (WorkEmail t)
22
+ let value (WorkEmail e) = e
23
+
24
+ // ── Entity ─────────────────────────────────────────────────────────────────────
25
+
26
+ type Employee = {
27
+ id: string
28
+ name: string
29
+ email: string
30
+ department: string
31
+ role: string
32
+ status: string // "active" | "inactive"
33
+ joinedAt: string
34
+ }
@@ -0,0 +1,35 @@
1
+ module App.Server.Endpoints.Employees.Routes
2
+
3
+ open FffStack.Server
4
+ open Fable.Core.JsInterop
5
+ open App.Server.Workflows.Employees
6
+
7
+ let register (router: Router) : unit =
8
+ router
9
+
10
+ .Get("/api/employees", fun ctx ->
11
+ promise {
12
+ let db = Env.requireD1 ctx.Env "DB"
13
+ let! employees = getAll db
14
+ return Response.json (employees |> List.toArray)
15
+ })
16
+
17
+ .Post("/api/employees", fun ctx ->
18
+ promise {
19
+ let db = Env.requireD1 ctx.Env "DB"
20
+ let! body = ctx.Request.json<{| name: string; email: string; department: string; role: string; status: string |}>()
21
+ match! create db body.name body.email body.department body.role body.status with
22
+ | Error e -> return Response.badRequest e
23
+ | Ok emp -> return Response.created emp
24
+ })
25
+
26
+ .Delete("/api/employees/:id", fun ctx ->
27
+ promise {
28
+ let id = ctx.Params.["id"]
29
+ let db = Env.requireD1 ctx.Env "DB"
30
+ let! ok = delete db id
31
+ if ok then return Response.noContent ()
32
+ else return Response.notFound (sprintf "Employee '%s' not found" id)
33
+ })
34
+
35
+ |> ignore
@@ -0,0 +1,66 @@
1
+ <section class="card">
2
+ <div class="card-label">About</div>
3
+ <h2>fff-stack</h2>
4
+ <p class="about-lead">
5
+ A fullstack F# framework for <strong>Cloudflare Workers</strong>,
6
+ D1, and R2. Write your entire application — client, server, and
7
+ templates — in F#. Zero JavaScript by hand.
8
+ </p>
9
+
10
+ <h3>Architecture</h3>
11
+ <ul class="about-list">
12
+ <li>
13
+ <strong>FJSX</strong> — Svelte-style templates compiled to
14
+ optimised DOM operations. No virtual DOM.
15
+ </li>
16
+ <li>
17
+ <strong>F# Client Runtime</strong> — class-based components
18
+ with <code>OnMount</code> / <code>OnUpdate</code> /
19
+ <code>OnDispose</code> lifecycle hooks.
20
+ </li>
21
+ <li>
22
+ <strong>F# Server Runtime</strong> — chainable Router,
23
+ D1/R2 bindings, Validation DSL, and Middleware pipeline.
24
+ </li>
25
+ <li>
26
+ <strong>Islands Architecture</strong> — the server renders
27
+ full HTML; client JavaScript only hydrates interactive islands.
28
+ </li>
29
+ </ul>
30
+
31
+ <h3>Quick Start</h3>
32
+ <pre class="code-block"><code>bunx create-fff-app my-app
33
+ cd my-app &amp;&amp; bun run demo # dev server (no .NET needed)
34
+ bun dev # full stack with wrangler
35
+ wrangler deploy # ship to Cloudflare</code></pre>
36
+ </section>
37
+
38
+ <section class="card">
39
+ <div class="card-label">Stack</div>
40
+ <div class="stack-grid">
41
+ <div class="stack-item">
42
+ <span class="stack-name">Language</span>
43
+ <span class="stack-value">F# (Fable → JS)</span>
44
+ </div>
45
+ <div class="stack-item">
46
+ <span class="stack-name">Runtime</span>
47
+ <span class="stack-value">Cloudflare Workers</span>
48
+ </div>
49
+ <div class="stack-item">
50
+ <span class="stack-name">Database</span>
51
+ <span class="stack-value">Cloudflare D1 (SQLite)</span>
52
+ </div>
53
+ <div class="stack-item">
54
+ <span class="stack-name">Storage</span>
55
+ <span class="stack-value">Cloudflare R2</span>
56
+ </div>
57
+ <div class="stack-item">
58
+ <span class="stack-name">Templates</span>
59
+ <span class="stack-value">FJSX (custom compiler)</span>
60
+ </div>
61
+ <div class="stack-item">
62
+ <span class="stack-name">Dev Tool</span>
63
+ <span class="stack-value">Bun</span>
64
+ </div>
65
+ </div>
66
+ </section>
@@ -0,0 +1,66 @@
1
+ <section class="card">
2
+ <div class="list-header">
3
+ <h1>Edit Employee</h1>
4
+ <a href="/" class="btn btn-ghost btn-sm">← Back to list</a>
5
+ </div>
6
+
7
+ <form method="POST" action="/employees/update">
8
+ <input type="hidden" name="id" value={this.id} />
9
+ <div class="form-row">
10
+ <div class="form-group">
11
+ <label for="name">Full Name</label>
12
+ <input type="text" id="name" name="name" value={this.name} required />
13
+ </div>
14
+ <div class="form-group">
15
+ <label for="email">Work Email</label>
16
+ <input type="email" id="email" name="email" value={this.email} required />
17
+ </div>
18
+ </div>
19
+ <div class="form-row">
20
+ <div class="form-group">
21
+ <label for="department">Department</label>
22
+ <select id="department" name="department" required>
23
+ {#each this.departments as dept}
24
+ {#if dept.isSelected}
25
+ <option value={dept.value} selected="selected">{dept.value}</option>
26
+ {:else}
27
+ <option value={dept.value}>{dept.value}</option>
28
+ {/if}
29
+ {/each}
30
+ </select>
31
+ </div>
32
+ <div class="form-group">
33
+ <label for="role">Job Title</label>
34
+ <input type="text" id="role" name="role" value={this.role} required />
35
+ </div>
36
+ </div>
37
+ <div class="form-group">
38
+ <label>Status</label>
39
+ <div class="radio-group">
40
+ {#if this.statusIsActive}
41
+ <label class="form-checkbox">
42
+ <input type="radio" name="status" value="active" checked />
43
+ Active
44
+ </label>
45
+ <label class="form-checkbox">
46
+ <input type="radio" name="status" value="inactive" />
47
+ Inactive
48
+ </label>
49
+ {:else}
50
+ <label class="form-checkbox">
51
+ <input type="radio" name="status" value="active" />
52
+ Active
53
+ </label>
54
+ <label class="form-checkbox">
55
+ <input type="radio" name="status" value="inactive" checked />
56
+ Inactive
57
+ </label>
58
+ {/if}
59
+ </div>
60
+ </div>
61
+ <div class="form-actions">
62
+ <button type="submit" class="btn btn-primary">Save Changes</button>
63
+ <a href="/" class="btn btn-secondary">Cancel</a>
64
+ </div>
65
+ </form>
66
+ </section>
@@ -0,0 +1,54 @@
1
+ <section class="card">
2
+ <div class="list-header">
3
+ <h1>Employees</h1>
4
+ <a href="/employees/new" class="btn btn-primary">Add Employee</a>
5
+ </div>
6
+
7
+ <div class="stat-bar">
8
+ <span class="badge badge-neutral">{this.total} Total</span>
9
+ <span class="badge badge-success">{this.active} Active</span>
10
+ </div>
11
+
12
+ {#if this.isEmpty}
13
+ <p class="empty-state">No employees yet. Add the first one using the button above.</p>
14
+ {:else}
15
+ <table class="data-table">
16
+ <thead>
17
+ <tr>
18
+ <th>Name</th>
19
+ <th>Email</th>
20
+ <th>Department</th>
21
+ <th>Job Title</th>
22
+ <th>Status</th>
23
+ <th>Joined</th>
24
+ <th></th>
25
+ </tr>
26
+ </thead>
27
+ <tbody>
28
+ {#each this.employees as emp}
29
+ <tr>
30
+ <td class="font-medium">{emp.name}</td>
31
+ <td class="text-muted">{emp.email}</td>
32
+ <td>{emp.department}</td>
33
+ <td>{emp.role}</td>
34
+ <td>
35
+ {#if emp.isActive}
36
+ <span class="badge badge-success">Active</span>
37
+ {:else}
38
+ <span class="badge badge-neutral">Inactive</span>
39
+ {/if}
40
+ </td>
41
+ <td class="text-muted text-mono">{emp.joinedAt}</td>
42
+ <td class="actions">
43
+ <a href={emp.editUrl} class="btn btn-ghost btn-sm">Edit</a>
44
+ <form method="POST" action="/employees/delete">
45
+ <input type="hidden" name="id" value={emp.id} />
46
+ <button type="submit" class="btn btn-ghost btn-sm" style="color:var(--fff-danger)">Remove</button>
47
+ </form>
48
+ </td>
49
+ </tr>
50
+ {/each}
51
+ </tbody>
52
+ </table>
53
+ {/if}
54
+ </section>
@@ -0,0 +1,55 @@
1
+ <section class="card">
2
+ <div class="list-header">
3
+ <h1>Add Employee</h1>
4
+ <a href="/" class="btn btn-ghost btn-sm">← Back to list</a>
5
+ </div>
6
+
7
+ <form method="POST" action="/employees">
8
+ <div class="form-row">
9
+ <div class="form-group">
10
+ <label for="name">Full Name</label>
11
+ <input type="text" id="name" name="name" placeholder="Jane Smith" required />
12
+ </div>
13
+ <div class="form-group">
14
+ <label for="email">Work Email</label>
15
+ <input type="email" id="email" name="email" placeholder="jane@company.com" required />
16
+ </div>
17
+ </div>
18
+ <div class="form-row">
19
+ <div class="form-group">
20
+ <label for="department">Department</label>
21
+ <select id="department" name="department" required>
22
+ <option value="">Select department…</option>
23
+ <option value="Engineering">Engineering</option>
24
+ <option value="Design">Design</option>
25
+ <option value="Sales">Sales</option>
26
+ <option value="Marketing">Marketing</option>
27
+ <option value="Operations">Operations</option>
28
+ <option value="HR">HR</option>
29
+ <option value="Finance">Finance</option>
30
+ </select>
31
+ </div>
32
+ <div class="form-group">
33
+ <label for="role">Job Title</label>
34
+ <input type="text" id="role" name="role" placeholder="Software Engineer" required />
35
+ </div>
36
+ </div>
37
+ <div class="form-group">
38
+ <label>Status</label>
39
+ <div class="radio-group">
40
+ <label class="form-checkbox">
41
+ <input type="radio" name="status" value="active" checked />
42
+ Active
43
+ </label>
44
+ <label class="form-checkbox">
45
+ <input type="radio" name="status" value="inactive" />
46
+ Inactive
47
+ </label>
48
+ </div>
49
+ </div>
50
+ <div class="form-actions">
51
+ <button type="submit" class="btn btn-primary">Add Employee</button>
52
+ <a href="/" class="btn btn-secondary">Cancel</a>
53
+ </div>
54
+ </form>
55
+ </section>
@@ -0,0 +1,40 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>{this.title} — Staff</title>
7
+ <link rel="stylesheet" href="/style.css"/>
8
+ </head>
9
+ <body>
10
+ <header class="site-header">
11
+ <div class="container">
12
+ <a href="/" class="logo">FFF<span>·</span>Stack</a>
13
+ <nav class="site-nav">
14
+ {#if this.homeActive}
15
+ <a href="/" class="active">Employees</a>
16
+ {:else}
17
+ <a href="/">Employees</a>
18
+ {/if}
19
+ {#if this.aboutActive}
20
+ <a href="/about" class="active">About</a>
21
+ {:else}
22
+ <a href="/about">About</a>
23
+ {/if}
24
+ </nav>
25
+ </div>
26
+ </header>
27
+
28
+ <main>
29
+ {@this.body}
30
+ </main>
31
+
32
+ <footer class="site-footer">
33
+ <div class="container">
34
+ <small>Built with <strong>fff-stack</strong> · F# · FJSX · Cloudflare Workers</small>
35
+ </div>
36
+ </footer>
37
+
38
+ <script type="module" src="/app.js"></script>
39
+ </body>
40
+ </html>
@@ -0,0 +1,117 @@
1
+ module App.Server.Endpoints.Pages.Routes
2
+
3
+ open FffStack.Server
4
+ open Fable.Core
5
+ open Fable.Core.JsInterop
6
+ open App.Server.Workflows.Employees
7
+
8
+ let private deptChoices (current: string) =
9
+ [ "Engineering"; "Design"; "Sales"; "Marketing"; "Operations"; "HR"; "Finance" ]
10
+ |> List.map (fun d -> createObj [ "value" ==> d; "isSelected" ==> (d = current) ])
11
+ |> List.toArray
12
+
13
+ let private employeeRow (emp: Domain.Employees.Employee) =
14
+ createObj [
15
+ "id" ==> emp.id
16
+ "name" ==> emp.name
17
+ "email" ==> emp.email
18
+ "department" ==> emp.department
19
+ "role" ==> emp.role
20
+ "status" ==> emp.status
21
+ "joinedAt" ==> emp.joinedAt
22
+ "isActive" ==> (emp.status = "active")
23
+ "editUrl" ==> (sprintf "/employees/%s/edit" emp.id)
24
+ ]
25
+
26
+ let register (router: Router) : unit =
27
+ router
28
+
29
+ // GET / — employee list
30
+ .Get("/", fun ctx ->
31
+ promise {
32
+ let db = Env.requireD1 ctx.Env "DB"
33
+ let! employees = getAll db
34
+ let total = employees.Length
35
+ let active = employees |> List.filter (fun e -> e.status = "active") |> List.length
36
+ let flash = Flash.banner ctx.Request
37
+ let body = Templates.renderEmployeeList (createObj [
38
+ "employees" ==> (employees |> List.map employeeRow |> List.toArray)
39
+ "isEmpty" ==> (total = 0)
40
+ "total" ==> total
41
+ "active" ==> active
42
+ ])
43
+ return Response.html (Templates.shell "Employees" "/" (flash + body))
44
+ })
45
+
46
+ // GET /employees/new — add employee form
47
+ .Get("/employees/new", fun _ctx ->
48
+ promise {
49
+ return Response.html (Templates.shell "Add Employee" "/" (Templates.renderEmployeeNew (createObj [])))
50
+ })
51
+
52
+ // POST /employees — create employee (PRG)
53
+ .Post("/employees", fun ctx ->
54
+ promise {
55
+ let db = Env.requireD1 ctx.Env "DB"
56
+ let! form = Form.parseForm ctx.Request
57
+ let get f = Form.field f form |> Result.defaultValue ""
58
+ match! create db (get "name") (get "email") (get "department") (get "role") (get "status") with
59
+ | Error e -> return Flash.redirectWith "/" "error" e
60
+ | Ok emp ->
61
+ return Flash.redirectWith "/" "success"
62
+ (sprintf "%s has been added to the directory." emp.name)
63
+ })
64
+
65
+ // GET /employees/:id/edit — edit employee form
66
+ .Get("/employees/:id/edit", fun ctx ->
67
+ promise {
68
+ let id = ctx.Params.["id"]
69
+ let db = Env.requireD1 ctx.Env "DB"
70
+ match! getById db id with
71
+ | None -> return Response.notFound (sprintf "Employee '%s' not found" id)
72
+ | Some emp ->
73
+ let body = Templates.renderEmployeeEdit (createObj [
74
+ "id" ==> emp.id
75
+ "name" ==> emp.name
76
+ "email" ==> emp.email
77
+ "role" ==> emp.role
78
+ "status" ==> emp.status
79
+ "statusIsActive" ==> (emp.status = "active")
80
+ "departments" ==> deptChoices emp.department
81
+ ])
82
+ return Response.html (Templates.shell "Edit Employee" "/" body)
83
+ })
84
+
85
+ // POST /employees/update — update employee (PRG)
86
+ .Post("/employees/update", fun ctx ->
87
+ promise {
88
+ let db = Env.requireD1 ctx.Env "DB"
89
+ let! form = Form.parseForm ctx.Request
90
+ let get f = Form.field f form |> Result.defaultValue ""
91
+ match! update db (get "id") (get "name") (get "email") (get "department") (get "role") (get "status") with
92
+ | Error e -> return Flash.redirectWith "/" "error" e
93
+ | Ok emp ->
94
+ return Flash.redirectWith "/" "success"
95
+ (sprintf "%s has been updated." emp.name)
96
+ })
97
+
98
+ // POST /employees/delete — delete employee (PRG)
99
+ .Post("/employees/delete", fun ctx ->
100
+ promise {
101
+ let db = Env.requireD1 ctx.Env "DB"
102
+ let! form = Form.parseForm ctx.Request
103
+ match Form.field "id" form with
104
+ | Error _ -> return Flash.redirectWith "/" "error" "Missing employee ID"
105
+ | Ok id ->
106
+ let! ok = delete db id
107
+ if ok then return Flash.redirectWith "/" "success" "Employee removed."
108
+ else return Flash.redirectWith "/" "error" "Employee not found."
109
+ })
110
+
111
+ // GET /about — tech stack info
112
+ .Get("/about", fun _ctx ->
113
+ promise {
114
+ return Response.html (Templates.shell "About" "/about" (Templates.renderAbout()))
115
+ })
116
+
117
+ |> ignore