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.
- package/package.json +21 -0
- package/src/index.ts +119 -0
- package/template/.config/dotnet-tools.json +5 -0
- package/template/dev-server.ts +415 -0
- package/template/index.html +23 -0
- package/template/migrate.ts +166 -0
- package/template/migrations/0001_init.sql +25 -0
- package/template/migrations/0002_employees.sql +16 -0
- package/template/package.json +29 -0
- package/template/public/app.js +156 -0
- package/template/public/runtime.js +149 -0
- package/template/public/style.css +769 -0
- package/template/schema.sql +25 -0
- package/template/src/client/App.fs +55 -0
- package/template/src/client/Client.fsproj +24 -0
- package/template/src/client/Nav.fs +75 -0
- package/template/src/server/Domain/Employees/Employee.fs +34 -0
- package/template/src/server/Endpoints/Employees/Routes.fs +35 -0
- package/template/src/server/Endpoints/Pages/About.fjsx +66 -0
- package/template/src/server/Endpoints/Pages/EmployeeEdit.fjsx +66 -0
- package/template/src/server/Endpoints/Pages/EmployeeList.fjsx +54 -0
- package/template/src/server/Endpoints/Pages/EmployeeNew.fjsx +55 -0
- package/template/src/server/Endpoints/Pages/Layout.fjsx +40 -0
- package/template/src/server/Endpoints/Pages/Routes.fs +117 -0
- package/template/src/server/Endpoints/Pages/Templates.fs +45 -0
- package/template/src/server/Server.fsproj +28 -0
- package/template/src/server/Worker.fs +45 -0
- package/template/src/server/Workflows/Employees/EmployeeWorkflows.fs +86 -0
- package/template/webpack.client.js +18 -0
- 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 && 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
|