@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.
Files changed (54) hide show
  1. package/README.md +9 -22
  2. package/dist/cli.js +2400 -47
  3. package/dist/templates/overlays/api/docs/API.md +9 -0
  4. package/dist/templates/overlays/server-htmx/public/assets/app.css +89 -0
  5. package/dist/templates/overlays/server-htmx/resources/views/errors/error.eta +14 -0
  6. package/dist/templates/overlays/server-htmx/resources/views/errors/forbidden.eta +4 -0
  7. package/dist/templates/overlays/server-htmx/resources/views/errors/not-found.eta +4 -0
  8. package/dist/templates/overlays/server-htmx/resources/views/layouts/app.eta +34 -0
  9. package/dist/templates/overlays/server-htmx/resources/views/organizations/_table.eta +23 -0
  10. package/dist/templates/overlays/server-htmx/resources/views/organizations/index.eta +37 -0
  11. package/dist/templates/overlays/server-htmx/resources/views/pages/home.eta +4 -0
  12. package/dist/templates/overlays/server-htmx/resources/views/partials/_flash.eta +3 -0
  13. package/dist/templates/overlays/spa-react/frontend/build.ts +17 -0
  14. package/dist/templates/overlays/spa-react/frontend/bun-env.d.ts +4 -0
  15. package/dist/templates/overlays/spa-react/frontend/bun.lock +51 -0
  16. package/dist/templates/overlays/spa-react/frontend/dev-server.ts +66 -0
  17. package/dist/templates/overlays/spa-react/frontend/index.html +12 -0
  18. package/dist/templates/overlays/spa-react/frontend/package.json +21 -0
  19. package/dist/templates/overlays/spa-react/frontend/src/App.tsx +80 -0
  20. package/dist/templates/overlays/spa-react/frontend/src/api/client.ts +86 -0
  21. package/dist/templates/overlays/spa-react/frontend/src/app.css +98 -0
  22. package/dist/templates/overlays/spa-react/frontend/src/auth/AuthContext.tsx +103 -0
  23. package/dist/templates/overlays/spa-react/frontend/src/auth/tokenStorage.ts +15 -0
  24. package/dist/templates/overlays/spa-react/frontend/src/main.tsx +22 -0
  25. package/dist/templates/overlays/spa-react/frontend/src/pages/LoginPage.tsx +67 -0
  26. package/dist/templates/overlays/spa-react/frontend/src/pages/NotFoundPage.tsx +12 -0
  27. package/dist/templates/overlays/spa-react/frontend/src/pages/OrganizationsPage.tsx +69 -0
  28. package/dist/templates/overlays/spa-react/frontend/src/pages/ProjectsPage.tsx +65 -0
  29. package/dist/templates/overlays/spa-react/frontend/src/pages/TasksPage.tsx +65 -0
  30. package/dist/templates/{templates → overlays/spa-react/frontend}/tsconfig.json +9 -6
  31. package/dist/templates/package.json +1 -1
  32. package/package.json +3 -3
  33. package/dist/templates/templates/.env.example +0 -22
  34. package/dist/templates/templates/docker-compose.yml +0 -14
  35. package/dist/templates/templates/package.json +0 -22
  36. package/dist/templates/templates/public/assets/site.css +0 -29
  37. package/dist/templates/templates/src/bootstrap/config.ts +0 -18
  38. package/dist/templates/templates/src/bootstrap/createApp.ts +0 -93
  39. package/dist/templates/templates/src/bootstrap/database.ts +0 -30
  40. package/dist/templates/templates/src/bootstrap/preload.ts +0 -5
  41. package/dist/templates/templates/src/bootstrap/providers/auth.ts +0 -41
  42. package/dist/templates/templates/src/bootstrap/providers/cache.ts +0 -28
  43. package/dist/templates/templates/src/bootstrap/providers/config.ts +0 -27
  44. package/dist/templates/templates/src/bootstrap/providers/index.ts +0 -14
  45. package/dist/templates/templates/src/bootstrap/providers/storage.ts +0 -11
  46. package/dist/templates/templates/src/bootstrap/server.ts +0 -25
  47. package/dist/templates/templates/src/db/fresh.ts +0 -19
  48. package/dist/templates/templates/src/db/migrate.ts +0 -35
  49. package/dist/templates/templates/src/lib/view.ts +0 -21
  50. package/dist/templates/templates/src/modules/site/index.ts +0 -29
  51. package/dist/templates/templates/src/routes.ts +0 -6
  52. package/dist/templates/templates/strata.config.ts +0 -7
  53. package/dist/templates/templates/views/home.eta +0 -5
  54. 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,4 @@
1
+ <section class="page-header">
2
+ <h1><%= it.title ?? "Forbidden" %></h1>
3
+ <p><%= it.message ?? "You do not have permission to view this page." %></p>
4
+ </section>
@@ -0,0 +1,4 @@
1
+ <section class="page-header">
2
+ <h1><%= it.title ?? "Not Found" %></h1>
3
+ <p><%= it.message ?? "The page you requested was not found." %></p>
4
+ </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,4 @@
1
+ <section class="page-header">
2
+ <h1>Welcome</h1>
3
+ <p class="hint">Replace this page by adding modules with webRoutes().</p>
4
+ </section>
@@ -0,0 +1,3 @@
1
+ <% if (it.flash) { %>
2
+ <div class="flash flash-<%= it.flash.level %>" role="status"><%= it.flash.message %></div>
3
+ <% } %>
@@ -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,4 @@
1
+ declare module "*.html" {
2
+ const value: import("bun").HTMLBundle;
3
+ export default value;
4
+ }
@@ -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
+ }