@getstrata/starter 0.1.8 → 0.1.9
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/README.md +9 -22
- package/dist/cli.js +2400 -47
- package/dist/templates/overlays/api/docs/API.md +9 -0
- package/dist/templates/overlays/server-htmx/public/assets/app.css +89 -0
- package/dist/templates/overlays/server-htmx/resources/views/errors/error.eta +14 -0
- package/dist/templates/overlays/server-htmx/resources/views/errors/forbidden.eta +4 -0
- package/dist/templates/overlays/server-htmx/resources/views/errors/not-found.eta +4 -0
- package/dist/templates/overlays/server-htmx/resources/views/layouts/app.eta +34 -0
- package/dist/templates/overlays/server-htmx/resources/views/organizations/_table.eta +23 -0
- package/dist/templates/overlays/server-htmx/resources/views/organizations/index.eta +37 -0
- package/dist/templates/overlays/server-htmx/resources/views/pages/home.eta +4 -0
- package/dist/templates/overlays/server-htmx/resources/views/partials/_flash.eta +3 -0
- package/dist/templates/overlays/spa-react/frontend/build.ts +17 -0
- package/dist/templates/overlays/spa-react/frontend/bun-env.d.ts +4 -0
- package/dist/templates/overlays/spa-react/frontend/bun.lock +51 -0
- package/dist/templates/overlays/spa-react/frontend/dev-server.ts +66 -0
- package/dist/templates/overlays/spa-react/frontend/index.html +12 -0
- package/dist/templates/overlays/spa-react/frontend/package.json +21 -0
- package/dist/templates/overlays/spa-react/frontend/src/App.tsx +80 -0
- package/dist/templates/overlays/spa-react/frontend/src/api/client.ts +86 -0
- package/dist/templates/overlays/spa-react/frontend/src/app.css +98 -0
- package/dist/templates/overlays/spa-react/frontend/src/auth/AuthContext.tsx +103 -0
- package/dist/templates/overlays/spa-react/frontend/src/auth/tokenStorage.ts +15 -0
- package/dist/templates/overlays/spa-react/frontend/src/main.tsx +22 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/LoginPage.tsx +67 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/NotFoundPage.tsx +12 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/OrganizationsPage.tsx +69 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/ProjectsPage.tsx +65 -0
- package/dist/templates/overlays/spa-react/frontend/src/pages/TasksPage.tsx +65 -0
- package/dist/templates/{templates → overlays/spa-react/frontend}/tsconfig.json +9 -6
- package/dist/templates/package.json +1 -1
- package/package.json +3 -3
- package/dist/templates/templates/.env.example +0 -22
- package/dist/templates/templates/docker-compose.yml +0 -14
- package/dist/templates/templates/package.json +0 -22
- package/dist/templates/templates/public/assets/site.css +0 -29
- package/dist/templates/templates/src/bootstrap/config.ts +0 -18
- package/dist/templates/templates/src/bootstrap/createApp.ts +0 -93
- package/dist/templates/templates/src/bootstrap/database.ts +0 -30
- package/dist/templates/templates/src/bootstrap/preload.ts +0 -5
- package/dist/templates/templates/src/bootstrap/providers/auth.ts +0 -41
- package/dist/templates/templates/src/bootstrap/providers/cache.ts +0 -28
- package/dist/templates/templates/src/bootstrap/providers/config.ts +0 -27
- package/dist/templates/templates/src/bootstrap/providers/index.ts +0 -14
- package/dist/templates/templates/src/bootstrap/providers/storage.ts +0 -11
- package/dist/templates/templates/src/bootstrap/server.ts +0 -25
- package/dist/templates/templates/src/db/fresh.ts +0 -19
- package/dist/templates/templates/src/db/migrate.ts +0 -35
- package/dist/templates/templates/src/lib/view.ts +0 -21
- package/dist/templates/templates/src/modules/site/index.ts +0 -29
- package/dist/templates/templates/src/routes.ts +0 -6
- package/dist/templates/templates/strata.config.ts +0 -7
- package/dist/templates/templates/views/home.eta +0 -5
- package/dist/templates/templates/views/layouts/app.eta +0 -18
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# API-only scaffold
|
|
2
|
+
|
|
3
|
+
This template enables `FRONTEND_MODE=api`.
|
|
4
|
+
|
|
5
|
+
- JSON API routes under `/api/v1/*`
|
|
6
|
+
- No server-rendered views or SPA assets
|
|
7
|
+
- Use bearer tokens or dev auth headers in tests (`actingAs`, `postJson`)
|
|
8
|
+
|
|
9
|
+
Run `bun run cli new --template=api` to copy this note and set the env flag.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
font-family: system-ui, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
body {
|
|
7
|
+
margin: 0;
|
|
8
|
+
background: #f6f7fb;
|
|
9
|
+
color: #1f2937;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.site-header {
|
|
13
|
+
display: flex;
|
|
14
|
+
justify-content: space-between;
|
|
15
|
+
align-items: center;
|
|
16
|
+
padding: 1rem 1.5rem;
|
|
17
|
+
background: #111827;
|
|
18
|
+
color: #f9fafb;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.site-header a {
|
|
22
|
+
color: inherit;
|
|
23
|
+
text-decoration: none;
|
|
24
|
+
margin-right: 1rem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.site-main {
|
|
28
|
+
max-width: 960px;
|
|
29
|
+
margin: 0 auto;
|
|
30
|
+
padding: 1.5rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.page-header,
|
|
34
|
+
.create-form,
|
|
35
|
+
.card {
|
|
36
|
+
background: #fff;
|
|
37
|
+
border-radius: 0.75rem;
|
|
38
|
+
padding: 1.25rem;
|
|
39
|
+
margin-bottom: 1rem;
|
|
40
|
+
box-shadow: 0 1px 2px rgb(15 23 42 / 0.08);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.stack-form {
|
|
44
|
+
display: grid;
|
|
45
|
+
gap: 0.75rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.flash {
|
|
49
|
+
padding: 0.75rem 1rem;
|
|
50
|
+
border-radius: 0.5rem;
|
|
51
|
+
margin-bottom: 1rem;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.flash-success {
|
|
55
|
+
background: #ecfdf5;
|
|
56
|
+
color: #065f46;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.flash-error {
|
|
60
|
+
background: #fef2f2;
|
|
61
|
+
color: #991b1b;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.form-errors {
|
|
65
|
+
background: #fef2f2;
|
|
66
|
+
color: #991b1b;
|
|
67
|
+
padding: 0.75rem 1rem;
|
|
68
|
+
border-radius: 0.5rem;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.inline-form {
|
|
72
|
+
display: inline;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.hint {
|
|
76
|
+
color: #6b7280;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
table {
|
|
80
|
+
width: 100%;
|
|
81
|
+
border-collapse: collapse;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
th,
|
|
85
|
+
td {
|
|
86
|
+
padding: 0.5rem;
|
|
87
|
+
border-bottom: 1px solid #e5e7eb;
|
|
88
|
+
text-align: left;
|
|
89
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<section class="page-header">
|
|
2
|
+
<h1><%= it.title ?? "Something went wrong" %></h1>
|
|
3
|
+
<p><%= it.message ?? "Please try again." %></p>
|
|
4
|
+
<% if (it.errors) { %>
|
|
5
|
+
<ul class="error-list">
|
|
6
|
+
<% for (const [field, messages] of Object.entries(it.errors)) { %>
|
|
7
|
+
<% for (const message of messages) { %>
|
|
8
|
+
<li><%= field %>: <%= message %></li>
|
|
9
|
+
<% } %>
|
|
10
|
+
<% } %>
|
|
11
|
+
</ul>
|
|
12
|
+
<p><a href="javascript:history.back()">Go back</a></p>
|
|
13
|
+
<% } %>
|
|
14
|
+
</section>
|
|
@@ -0,0 +1,34 @@
|
|
|
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.0" />
|
|
6
|
+
<title><%= it.title ?? "Strata" %></title>
|
|
7
|
+
<meta name="csrf-token" content="<%= it.csrfToken ?? "" %>" />
|
|
8
|
+
<meta name="htmx-config" content='{"inlineStyleNonce":"<%= it.cspNonce ?? "" %>"}' />
|
|
9
|
+
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
10
|
+
<script nonce="<%= it.cspNonce ?? "" %>">
|
|
11
|
+
document.addEventListener("htmx:configRequest", (event) => {
|
|
12
|
+
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
|
|
13
|
+
if (token) {
|
|
14
|
+
event.detail.headers["X-CSRF-Token"] = token;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
</script>
|
|
18
|
+
<link rel="stylesheet" href="/assets/app.css" />
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<header class="site-header">
|
|
22
|
+
<a href="/organizations" class="brand">Strata</a>
|
|
23
|
+
<nav>
|
|
24
|
+
<a href="/organizations">Organizations</a>
|
|
25
|
+
</nav>
|
|
26
|
+
</header>
|
|
27
|
+
<main class="site-main">
|
|
28
|
+
<% if (it.flash) { %>
|
|
29
|
+
<div class="flash flash-<%= it.flash.level %>" role="status"><%= it.flash.message %></div>
|
|
30
|
+
<% } %>
|
|
31
|
+
<%~ it.body %>
|
|
32
|
+
</main>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<table>
|
|
2
|
+
<thead>
|
|
3
|
+
<tr>
|
|
4
|
+
<th>Name</th>
|
|
5
|
+
<th>Slug</th>
|
|
6
|
+
<th></th>
|
|
7
|
+
</tr>
|
|
8
|
+
</thead>
|
|
9
|
+
<tbody>
|
|
10
|
+
<% for (const organization of it.organizations ?? []) { %>
|
|
11
|
+
<tr>
|
|
12
|
+
<td><%= organization.name %></td>
|
|
13
|
+
<td><code><%= organization.slug %></code></td>
|
|
14
|
+
<td>
|
|
15
|
+
<form method="post" action="/organizations/<%= organization.id %>/delete" class="inline-form">
|
|
16
|
+
<input type="hidden" name="_token" value="<%= it.csrfToken ?? "" %>" />
|
|
17
|
+
<button type="submit">Delete</button>
|
|
18
|
+
</form>
|
|
19
|
+
</td>
|
|
20
|
+
</tr>
|
|
21
|
+
<% } %>
|
|
22
|
+
</tbody>
|
|
23
|
+
</table>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<section class="page-header">
|
|
2
|
+
<h1>Organizations</h1>
|
|
3
|
+
<p class="hint">Sample HTMX CRUD scaffold with CSRF and flash messages.</p>
|
|
4
|
+
</section>
|
|
5
|
+
|
|
6
|
+
<section class="create-form">
|
|
7
|
+
<h2>Create organization</h2>
|
|
8
|
+
|
|
9
|
+
<% if (Object.keys(it.errors ?? {}).length > 0) { %>
|
|
10
|
+
<div class="form-errors" role="alert">
|
|
11
|
+
<% for (const [field, messages] of Object.entries(it.errors)) { %>
|
|
12
|
+
<% for (const message of messages) { %>
|
|
13
|
+
<p><strong><%= field %></strong>: <%= message %></p>
|
|
14
|
+
<% } %>
|
|
15
|
+
<% } %>
|
|
16
|
+
</div>
|
|
17
|
+
<% } %>
|
|
18
|
+
|
|
19
|
+
<form method="post" action="/organizations" class="stack-form">
|
|
20
|
+
<input type="hidden" name="_token" value="<%= it.csrfToken ?? "" %>" />
|
|
21
|
+
<label>
|
|
22
|
+
Name
|
|
23
|
+
<input type="text" name="name" value="<%= it.old?.name ?? '' %>" required />
|
|
24
|
+
</label>
|
|
25
|
+
|
|
26
|
+
<label>
|
|
27
|
+
Slug
|
|
28
|
+
<input type="text" name="slug" value="<%= it.old?.slug ?? '' %>" required />
|
|
29
|
+
</label>
|
|
30
|
+
|
|
31
|
+
<button type="submit">Create</button>
|
|
32
|
+
</form>
|
|
33
|
+
</section>
|
|
34
|
+
|
|
35
|
+
<div id="org-table">
|
|
36
|
+
<%~ include('./_table', it) %>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const result = await Bun.build({
|
|
2
|
+
entrypoints: ["./index.html"],
|
|
3
|
+
outdir: "./dist",
|
|
4
|
+
target: "browser",
|
|
5
|
+
minify: true,
|
|
6
|
+
publicPath: "/app/",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
if (!result.success) {
|
|
10
|
+
for (const log of result.logs) {
|
|
11
|
+
console.error(log);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
throw new Error("Frontend build failed.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log(`Built ${result.outputs.length} frontend files into dist/`);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "strata-frontend",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"react": "^19.1.1",
|
|
9
|
+
"react-dom": "^19.1.1",
|
|
10
|
+
"react-router-dom": "^7.8.0",
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/bun": "^1.4.0",
|
|
14
|
+
"@types/react": "^19.1.10",
|
|
15
|
+
"@types/react-dom": "^19.1.7",
|
|
16
|
+
"typescript": "^5.9.2",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
"packages": {
|
|
21
|
+
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
|
|
22
|
+
|
|
23
|
+
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
|
24
|
+
|
|
25
|
+
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
|
26
|
+
|
|
27
|
+
"@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
|
|
28
|
+
|
|
29
|
+
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
|
|
30
|
+
|
|
31
|
+
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
|
32
|
+
|
|
33
|
+
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
34
|
+
|
|
35
|
+
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
|
36
|
+
|
|
37
|
+
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
|
38
|
+
|
|
39
|
+
"react-router": ["react-router@7.18.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg=="],
|
|
40
|
+
|
|
41
|
+
"react-router-dom": ["react-router-dom@7.18.2", "", { "dependencies": { "react-router": "7.18.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw=="],
|
|
42
|
+
|
|
43
|
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
|
44
|
+
|
|
45
|
+
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
|
46
|
+
|
|
47
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
48
|
+
|
|
49
|
+
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import index from "./index.html";
|
|
3
|
+
|
|
4
|
+
const API_ORIGIN = process.env.API_ORIGIN ?? "http://localhost:3000";
|
|
5
|
+
const PORT = Number(process.env.FRONTEND_PORT ?? "5173");
|
|
6
|
+
const PREVIEW = process.argv.includes("--preview");
|
|
7
|
+
const DIST_DIRECTORY = join(import.meta.dir, "dist");
|
|
8
|
+
|
|
9
|
+
async function proxyToApi(request: Request): Promise<Response> {
|
|
10
|
+
const incoming = new URL(request.url);
|
|
11
|
+
const target = new URL(`${incoming.pathname}${incoming.search}`, API_ORIGIN);
|
|
12
|
+
const headers = new Headers(request.headers);
|
|
13
|
+
headers.delete("host");
|
|
14
|
+
|
|
15
|
+
return fetch(target, {
|
|
16
|
+
method: request.method,
|
|
17
|
+
headers,
|
|
18
|
+
body: request.body,
|
|
19
|
+
redirect: "manual",
|
|
20
|
+
verbose: false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function servePreview(request: Request): Promise<Response> {
|
|
25
|
+
const pathname = new URL(request.url).pathname;
|
|
26
|
+
const relativePath = pathname.replace(/^\/app\/?/, "");
|
|
27
|
+
const asset = Bun.file(join(DIST_DIRECTORY, relativePath));
|
|
28
|
+
|
|
29
|
+
if (relativePath.length > 0 && (await asset.exists())) {
|
|
30
|
+
return new Response(asset);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const indexFile = Bun.file(join(DIST_DIRECTORY, "index.html"));
|
|
34
|
+
|
|
35
|
+
if (await indexFile.exists()) {
|
|
36
|
+
return new Response(indexFile, {
|
|
37
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return new Response("SPA build not found. Run `bun run build`.", { status: 503 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const server = Bun.serve({
|
|
45
|
+
port: PORT,
|
|
46
|
+
development: PREVIEW
|
|
47
|
+
? false
|
|
48
|
+
: {
|
|
49
|
+
hmr: true,
|
|
50
|
+
console: true,
|
|
51
|
+
},
|
|
52
|
+
routes: {
|
|
53
|
+
"/api/*": proxyToApi,
|
|
54
|
+
"/health": proxyToApi,
|
|
55
|
+
"/ready": proxyToApi,
|
|
56
|
+
"/app": PREVIEW ? servePreview : index,
|
|
57
|
+
"/app/": PREVIEW ? servePreview : index,
|
|
58
|
+
"/app/*": PREVIEW ? servePreview : index,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
console.log(
|
|
63
|
+
PREVIEW
|
|
64
|
+
? `SPA preview on ${server.url}app/ (proxy ${API_ORIGIN})`
|
|
65
|
+
: `SPA dev server on ${server.url}app/ (proxy ${API_ORIGIN})`,
|
|
66
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
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.0" />
|
|
6
|
+
<title>Strata</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "strata-frontend",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "bun --hot dev-server.ts",
|
|
7
|
+
"build": "bun run build.ts",
|
|
8
|
+
"preview": "bun run build.ts && bun dev-server.ts --preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"react": "^19.1.1",
|
|
12
|
+
"react-dom": "^19.1.1",
|
|
13
|
+
"react-router-dom": "^7.8.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "^1.4.0",
|
|
17
|
+
"@types/react": "^19.1.10",
|
|
18
|
+
"@types/react-dom": "^19.1.7",
|
|
19
|
+
"typescript": "^5.9.2"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Link, Navigate, Route, Routes } from "react-router-dom";
|
|
2
|
+
import { useAuth } from "./auth/AuthContext";
|
|
3
|
+
import LoginPage from "./pages/LoginPage";
|
|
4
|
+
import NotFoundPage from "./pages/NotFoundPage";
|
|
5
|
+
import OrganizationsPage from "./pages/OrganizationsPage";
|
|
6
|
+
import ProjectsPage from "./pages/ProjectsPage";
|
|
7
|
+
import TasksPage from "./pages/TasksPage";
|
|
8
|
+
|
|
9
|
+
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
10
|
+
const { token, loading } = useAuth();
|
|
11
|
+
|
|
12
|
+
if (loading) {
|
|
13
|
+
return <p className="hint">Loading session…</p>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!token) {
|
|
17
|
+
return <Navigate to="/login" replace />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return children;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function AppShell() {
|
|
24
|
+
const { user, logout } = useAuth();
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="app-shell">
|
|
28
|
+
<header className="site-header">
|
|
29
|
+
<strong>Strata SPA</strong>
|
|
30
|
+
<nav>
|
|
31
|
+
<Link to="/organizations">Organizations</Link>
|
|
32
|
+
<Link to="/projects">Projects</Link>
|
|
33
|
+
<Link to="/tasks">Tasks</Link>
|
|
34
|
+
{user ? (
|
|
35
|
+
<>
|
|
36
|
+
<span className="hint">{user.email}</span>
|
|
37
|
+
<button type="button" className="secondary" onClick={logout}>
|
|
38
|
+
Sign out
|
|
39
|
+
</button>
|
|
40
|
+
</>
|
|
41
|
+
) : null}
|
|
42
|
+
</nav>
|
|
43
|
+
</header>
|
|
44
|
+
|
|
45
|
+
<Routes>
|
|
46
|
+
<Route path="/" element={<Navigate to="/organizations" replace />} />
|
|
47
|
+
<Route path="/login" element={<LoginPage />} />
|
|
48
|
+
<Route
|
|
49
|
+
path="/organizations"
|
|
50
|
+
element={
|
|
51
|
+
<ProtectedRoute>
|
|
52
|
+
<OrganizationsPage />
|
|
53
|
+
</ProtectedRoute>
|
|
54
|
+
}
|
|
55
|
+
/>
|
|
56
|
+
<Route
|
|
57
|
+
path="/projects"
|
|
58
|
+
element={
|
|
59
|
+
<ProtectedRoute>
|
|
60
|
+
<ProjectsPage />
|
|
61
|
+
</ProtectedRoute>
|
|
62
|
+
}
|
|
63
|
+
/>
|
|
64
|
+
<Route
|
|
65
|
+
path="/tasks"
|
|
66
|
+
element={
|
|
67
|
+
<ProtectedRoute>
|
|
68
|
+
<TasksPage />
|
|
69
|
+
</ProtectedRoute>
|
|
70
|
+
}
|
|
71
|
+
/>
|
|
72
|
+
<Route path="*" element={<NotFoundPage />} />
|
|
73
|
+
</Routes>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default function App() {
|
|
79
|
+
return <AppShell />;
|
|
80
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const API_PREFIX = "/api/v1";
|
|
2
|
+
|
|
3
|
+
interface ApiErrorBody {
|
|
4
|
+
error?: string;
|
|
5
|
+
details?: Record<string, string[]>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
class ApiError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
readonly status: number,
|
|
11
|
+
message: string,
|
|
12
|
+
readonly details?: Record<string, string[]>,
|
|
13
|
+
) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ApiError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ApiFetchOptions extends RequestInit {
|
|
20
|
+
token?: string | null;
|
|
21
|
+
etag?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
|
|
25
|
+
const headers = new Headers(options.headers);
|
|
26
|
+
|
|
27
|
+
if (options.token) {
|
|
28
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (options.etag) {
|
|
32
|
+
headers.set("if-match", options.etag);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (options.body && !headers.has("content-type")) {
|
|
36
|
+
headers.set("content-type", "application/json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const response = await fetch(`${API_PREFIX}${path}`, {
|
|
40
|
+
...options,
|
|
41
|
+
headers,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (response.status === 204) {
|
|
45
|
+
return undefined as T;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
49
|
+
|
|
50
|
+
if (!contentType.includes("application/json")) {
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new ApiError(response.status, `Request failed with ${response.status}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return undefined as T;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const body = (await response.json()) as T & ApiErrorBody;
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new ApiError(response.status, body.error ?? "Request failed", body.details);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return body;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fetchResourceEtag(path: string, token: string): Promise<string> {
|
|
68
|
+
const response = await fetch(`${API_PREFIX}${path}`, {
|
|
69
|
+
headers: { authorization: `Bearer ${token}` },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new ApiError(response.status, `Failed to load ETag for ${path}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const etag = response.headers.get("etag");
|
|
77
|
+
|
|
78
|
+
if (!etag) {
|
|
79
|
+
throw new ApiError(412, `Missing ETag for ${path}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return etag;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type { ApiErrorBody };
|
|
86
|
+
export { API_PREFIX, ApiError, apiFetch, fetchResourceEtag };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color: #0f172a;
|
|
3
|
+
background: #f8fafc;
|
|
4
|
+
font-family: system-ui, sans-serif;
|
|
5
|
+
line-height: 1.5;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
a {
|
|
17
|
+
color: #2563eb;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.app-shell {
|
|
21
|
+
max-width: 960px;
|
|
22
|
+
margin: 0 auto;
|
|
23
|
+
padding: 1.5rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.site-header {
|
|
27
|
+
display: flex;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: 1rem;
|
|
31
|
+
margin-bottom: 1.5rem;
|
|
32
|
+
padding-bottom: 1rem;
|
|
33
|
+
border-bottom: 1px solid #e2e8f0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.site-header nav {
|
|
37
|
+
display: flex;
|
|
38
|
+
gap: 1rem;
|
|
39
|
+
flex-wrap: wrap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.card {
|
|
43
|
+
background: #fff;
|
|
44
|
+
border: 1px solid #e2e8f0;
|
|
45
|
+
border-radius: 0.75rem;
|
|
46
|
+
padding: 1rem;
|
|
47
|
+
margin-bottom: 1rem;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.stack-form {
|
|
51
|
+
display: grid;
|
|
52
|
+
gap: 0.75rem;
|
|
53
|
+
max-width: 24rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.stack-form label {
|
|
57
|
+
display: grid;
|
|
58
|
+
gap: 0.25rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.stack-form input {
|
|
62
|
+
padding: 0.5rem 0.75rem;
|
|
63
|
+
border: 1px solid #cbd5e1;
|
|
64
|
+
border-radius: 0.5rem;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
button {
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
border: none;
|
|
70
|
+
border-radius: 0.5rem;
|
|
71
|
+
padding: 0.5rem 0.875rem;
|
|
72
|
+
background: #2563eb;
|
|
73
|
+
color: #fff;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
button.secondary {
|
|
77
|
+
background: #64748b;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.error {
|
|
81
|
+
color: #b91c1c;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
table {
|
|
85
|
+
width: 100%;
|
|
86
|
+
border-collapse: collapse;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
th,
|
|
90
|
+
td {
|
|
91
|
+
text-align: left;
|
|
92
|
+
padding: 0.5rem 0.75rem;
|
|
93
|
+
border-bottom: 1px solid #e2e8f0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.hint {
|
|
97
|
+
color: #64748b;
|
|
98
|
+
}
|