create-fff-app 0.1.10 → 0.1.12
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 +1 -1
- package/src/index.ts +9 -12
- package/template/dev-server.ts +98 -341
- package/template/migrations/0001_init.sql +7 -23
- package/template/package.json +12 -13
- package/template/schema.sql +9 -25
- package/template/src/client/Client.fsproj +1 -1
- package/template/src/server/Server.fsproj +0 -12
- package/template/src/server/Worker.fs +2 -32
- package/template/wrangler.toml +13 -15
- package/template/migrations/0002_employees.sql +0 -16
- package/template/src/server/Domain/Employees/Employee.fs +0 -34
- package/template/src/server/Endpoints/Employees/Routes.fs +0 -35
- package/template/src/server/Endpoints/Pages/About.fjsx +0 -66
- package/template/src/server/Endpoints/Pages/EmployeeEdit.fjsx +0 -66
- package/template/src/server/Endpoints/Pages/EmployeeList.fjsx +0 -54
- package/template/src/server/Endpoints/Pages/EmployeeNew.fjsx +0 -55
- package/template/src/server/Endpoints/Pages/Layout.fjsx +0 -40
- package/template/src/server/Endpoints/Pages/Routes.fs +0 -118
- package/template/src/server/Endpoints/Pages/Templates.fs +0 -44
- package/template/src/server/Workflows/Employees/EmployeeWorkflows.fs +0 -86
package/template/schema.sql
CHANGED
|
@@ -1,25 +1,9 @@
|
|
|
1
|
-
--
|
|
2
|
-
--
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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);
|
|
1
|
+
-- Reference schema (not applied automatically)
|
|
2
|
+
-- Use migrations/ for versioned changes: bun run migrate
|
|
3
|
+
|
|
4
|
+
-- Example:
|
|
5
|
+
-- CREATE TABLE IF NOT EXISTS items (
|
|
6
|
+
-- id TEXT PRIMARY KEY,
|
|
7
|
+
-- name TEXT NOT NULL,
|
|
8
|
+
-- created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
9
|
+
-- );
|
|
@@ -6,18 +6,6 @@
|
|
|
6
6
|
</PropertyGroup>
|
|
7
7
|
|
|
8
8
|
<ItemGroup>
|
|
9
|
-
<!-- Domain — pure types, no side effects -->
|
|
10
|
-
<Compile Include="Domain/Employees/Employee.fs" />
|
|
11
|
-
|
|
12
|
-
<!-- Workflows — business logic (may use Infrastructure) -->
|
|
13
|
-
<Compile Include="Workflows/Employees/EmployeeWorkflows.fs" />
|
|
14
|
-
|
|
15
|
-
<!-- Endpoints — HTTP layer (routes + page templates) -->
|
|
16
|
-
<Compile Include="Endpoints/Employees/Routes.fs" />
|
|
17
|
-
<Compile Include="Endpoints/Pages/Templates.fs" />
|
|
18
|
-
<Compile Include="Endpoints/Pages/Routes.fs" />
|
|
19
|
-
|
|
20
|
-
<!-- Entry point -->
|
|
21
9
|
<Compile Include="Worker.fs" />
|
|
22
10
|
</ItemGroup>
|
|
23
11
|
|
|
@@ -1,44 +1,14 @@
|
|
|
1
1
|
module App.Server.Worker
|
|
2
2
|
|
|
3
3
|
open FffStack.Server
|
|
4
|
-
open Fable.Core
|
|
5
4
|
|
|
6
5
|
let router = Router()
|
|
7
6
|
|
|
8
|
-
// ── API routes (JSON) ─────────────────────────────────────────────────────────
|
|
9
|
-
Endpoints.Employees.Routes.register router
|
|
10
|
-
|
|
11
|
-
// ── Page routes (HTML, MPA) ───────────────────────────────────────────────────
|
|
12
|
-
Endpoints.Pages.Routes.register router
|
|
13
|
-
|
|
14
|
-
// ── Error pages ───────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
7
|
router.OnNotFound(fun _ctx ->
|
|
17
|
-
promise {
|
|
18
|
-
let body = """
|
|
19
|
-
<section class="card">
|
|
20
|
-
<div class="card-label">404</div>
|
|
21
|
-
<h2>Page not found</h2>
|
|
22
|
-
<p class="text-muted" style="margin-top:0.5rem">The page you were looking for doesn't exist.</p>
|
|
23
|
-
<a href="/" class="btn btn-primary" style="display:inline-block;margin-top:1.25rem">← Back to Employees</a>
|
|
24
|
-
</section>"""
|
|
25
|
-
return Response.html (Endpoints.Pages.Templates.shell "Not Found" "" body)
|
|
26
|
-
}) |> ignore
|
|
8
|
+
promise { return Response.notFound "Not found" }) |> ignore
|
|
27
9
|
|
|
28
10
|
router.OnError(fun _ctx ex ->
|
|
29
|
-
|
|
30
|
-
let body =
|
|
31
|
-
sprintf """
|
|
32
|
-
<section class="card">
|
|
33
|
-
<div class="card-label">500</div>
|
|
34
|
-
<h2>Something went wrong</h2>
|
|
35
|
-
<p class="text-muted" style="margin-top:0.5rem">%s</p>
|
|
36
|
-
<a href="/" class="btn btn-secondary" style="display:inline-block;margin-top:1.25rem">← Back to Employees</a>
|
|
37
|
-
</section>"""
|
|
38
|
-
msg
|
|
39
|
-
promise {
|
|
40
|
-
return Response.html (Endpoints.Pages.Templates.shell "Error" "" body)
|
|
41
|
-
}) |> ignore
|
|
11
|
+
promise { return Response.html (sprintf "<h1>500</h1><p>%s</p>" ex.Message) }) |> ignore
|
|
42
12
|
|
|
43
13
|
// ── Cloudflare Workers entry point ────────────────────────────────────────────
|
|
44
14
|
|
package/template/wrangler.toml
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
name
|
|
2
|
-
main
|
|
1
|
+
name = "fff-stack-app"
|
|
2
|
+
main = "dist/server/Worker.js"
|
|
3
3
|
compatibility_date = "2024-09-23"
|
|
4
4
|
|
|
5
|
-
# Client assets
|
|
5
|
+
# Client assets (CSS, JS, images)
|
|
6
6
|
[assets]
|
|
7
7
|
directory = "./dist/client"
|
|
8
8
|
binding = "ASSETS"
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
[[d1_databases]]
|
|
12
|
-
binding = "DB"
|
|
13
|
-
database_name = "fff-stack-db"
|
|
14
|
-
database_id = "YOUR_D1_DATABASE_ID" # replace after: wrangler d1 create fff-stack-db
|
|
10
|
+
# ── Add bindings below as you need them ───────────────────────────────────────
|
|
15
11
|
|
|
16
|
-
# Cloudflare
|
|
17
|
-
[[
|
|
18
|
-
binding
|
|
19
|
-
|
|
20
|
-
#
|
|
12
|
+
# Cloudflare D1 (SQLite) — uncomment after: wrangler d1 create <name>
|
|
13
|
+
# [[d1_databases]]
|
|
14
|
+
# binding = "DB"
|
|
15
|
+
# database_name = "fff-stack-app-db"
|
|
16
|
+
# database_id = "YOUR_D1_DATABASE_ID"
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
# Cloudflare R2 (object storage) — uncomment after: wrangler r2 bucket create <name>
|
|
19
|
+
# [[r2_buckets]]
|
|
20
|
+
# binding = "BUCKET"
|
|
21
|
+
# bucket_name = "fff-stack-app-bucket"
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
-- Employee directory table
|
|
2
|
-
-- Applied automatically by: bun run migrate
|
|
3
|
-
|
|
4
|
-
CREATE TABLE IF NOT EXISTS employees (
|
|
5
|
-
id TEXT PRIMARY KEY,
|
|
6
|
-
name TEXT NOT NULL,
|
|
7
|
-
email TEXT NOT NULL UNIQUE,
|
|
8
|
-
department TEXT NOT NULL,
|
|
9
|
-
role TEXT NOT NULL,
|
|
10
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
11
|
-
joined_at TEXT NOT NULL DEFAULT (date('now'))
|
|
12
|
-
);
|
|
13
|
-
|
|
14
|
-
CREATE INDEX IF NOT EXISTS idx_employees_name ON employees(name);
|
|
15
|
-
CREATE INDEX IF NOT EXISTS idx_employees_department ON employees(department);
|
|
16
|
-
CREATE INDEX IF NOT EXISTS idx_employees_status ON employees(status);
|
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
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
|
|
@@ -1,66 +0,0 @@
|
|
|
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>
|
|
@@ -1,66 +0,0 @@
|
|
|
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>
|
|
@@ -1,54 +0,0 @@
|
|
|
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>
|
|
@@ -1,55 +0,0 @@
|
|
|
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>
|
|
@@ -1,40 +0,0 @@
|
|
|
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>
|
|
@@ -1,118 +0,0 @@
|
|
|
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.Domain.Employees
|
|
7
|
-
open App.Server.Workflows.Employees
|
|
8
|
-
|
|
9
|
-
let private deptChoices (current: string) =
|
|
10
|
-
[ "Engineering"; "Design"; "Sales"; "Marketing"; "Operations"; "HR"; "Finance" ]
|
|
11
|
-
|> List.map (fun d -> createObj [ "value" ==> d; "isSelected" ==> (d = current) ])
|
|
12
|
-
|> List.toArray
|
|
13
|
-
|
|
14
|
-
let private employeeRow (emp: Employee) =
|
|
15
|
-
createObj [
|
|
16
|
-
"id" ==> emp.id
|
|
17
|
-
"name" ==> emp.name
|
|
18
|
-
"email" ==> emp.email
|
|
19
|
-
"department" ==> emp.department
|
|
20
|
-
"role" ==> emp.role
|
|
21
|
-
"status" ==> emp.status
|
|
22
|
-
"joinedAt" ==> emp.joinedAt
|
|
23
|
-
"isActive" ==> (emp.status = "active")
|
|
24
|
-
"editUrl" ==> (sprintf "/employees/%s/edit" emp.id)
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
let register (router: Router) : unit =
|
|
28
|
-
router
|
|
29
|
-
|
|
30
|
-
// GET / — employee list
|
|
31
|
-
.Get("/", fun ctx ->
|
|
32
|
-
promise {
|
|
33
|
-
let db = Env.requireD1 ctx.Env "DB"
|
|
34
|
-
let! employees = getAll db
|
|
35
|
-
let total = employees.Length
|
|
36
|
-
let active = employees |> List.filter (fun e -> e.status = "active") |> List.length
|
|
37
|
-
let flash = Flash.banner ctx.Request
|
|
38
|
-
let body = Templates.renderEmployeeList (createObj [
|
|
39
|
-
"employees" ==> (employees |> List.map employeeRow |> List.toArray)
|
|
40
|
-
"isEmpty" ==> (total = 0)
|
|
41
|
-
"total" ==> total
|
|
42
|
-
"active" ==> active
|
|
43
|
-
])
|
|
44
|
-
return Response.html (Templates.shell "Employees" "/" (flash + body))
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
// GET /employees/new — add employee form
|
|
48
|
-
.Get("/employees/new", fun _ctx ->
|
|
49
|
-
promise {
|
|
50
|
-
return Response.html (Templates.shell "Add Employee" "/" (Templates.renderEmployeeNew (createObj [])))
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
// POST /employees — create employee (PRG)
|
|
54
|
-
.Post("/employees", fun ctx ->
|
|
55
|
-
promise {
|
|
56
|
-
let db = Env.requireD1 ctx.Env "DB"
|
|
57
|
-
let! form = Form.parseForm ctx.Request
|
|
58
|
-
let get f = Form.field f form |> Result.defaultValue ""
|
|
59
|
-
match! create db (get "name") (get "email") (get "department") (get "role") (get "status") with
|
|
60
|
-
| Error e -> return Flash.redirectWith "/" "error" e
|
|
61
|
-
| Ok emp ->
|
|
62
|
-
return Flash.redirectWith "/" "success"
|
|
63
|
-
(sprintf "%s has been added to the directory." emp.name)
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
// GET /employees/:id/edit — edit employee form
|
|
67
|
-
.Get("/employees/:id/edit", fun ctx ->
|
|
68
|
-
promise {
|
|
69
|
-
let id = ctx.Params.["id"]
|
|
70
|
-
let db = Env.requireD1 ctx.Env "DB"
|
|
71
|
-
match! getById db id with
|
|
72
|
-
| None -> return Response.notFound (sprintf "Employee '%s' not found" id)
|
|
73
|
-
| Some emp ->
|
|
74
|
-
let body = Templates.renderEmployeeEdit (createObj [
|
|
75
|
-
"id" ==> emp.id
|
|
76
|
-
"name" ==> emp.name
|
|
77
|
-
"email" ==> emp.email
|
|
78
|
-
"role" ==> emp.role
|
|
79
|
-
"status" ==> emp.status
|
|
80
|
-
"statusIsActive" ==> (emp.status = "active")
|
|
81
|
-
"departments" ==> deptChoices emp.department
|
|
82
|
-
])
|
|
83
|
-
return Response.html (Templates.shell "Edit Employee" "/" body)
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
// POST /employees/update — update employee (PRG)
|
|
87
|
-
.Post("/employees/update", fun ctx ->
|
|
88
|
-
promise {
|
|
89
|
-
let db = Env.requireD1 ctx.Env "DB"
|
|
90
|
-
let! form = Form.parseForm ctx.Request
|
|
91
|
-
let get f = Form.field f form |> Result.defaultValue ""
|
|
92
|
-
match! update db (get "id") (get "name") (get "email") (get "department") (get "role") (get "status") with
|
|
93
|
-
| Error e -> return Flash.redirectWith "/" "error" e
|
|
94
|
-
| Ok emp ->
|
|
95
|
-
return Flash.redirectWith "/" "success"
|
|
96
|
-
(sprintf "%s has been updated." emp.name)
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
// POST /employees/delete — delete employee (PRG)
|
|
100
|
-
.Post("/employees/delete", fun ctx ->
|
|
101
|
-
promise {
|
|
102
|
-
let db = Env.requireD1 ctx.Env "DB"
|
|
103
|
-
let! form = Form.parseForm ctx.Request
|
|
104
|
-
match Form.field "id" form with
|
|
105
|
-
| Error _ -> return Flash.redirectWith "/" "error" "Missing employee ID"
|
|
106
|
-
| Ok id ->
|
|
107
|
-
let! ok = delete db id
|
|
108
|
-
if ok then return Flash.redirectWith "/" "success" "Employee removed."
|
|
109
|
-
else return Flash.redirectWith "/" "error" "Employee not found."
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
// GET /about — tech stack info
|
|
113
|
-
.Get("/about", fun _ctx ->
|
|
114
|
-
promise {
|
|
115
|
-
return Response.html (Templates.shell "About" "/about" (Templates.renderAbout()))
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
|> ignore
|