Skip to content

Commit 95567a4

Browse files
committed
implement example client app
1 parent 4f49d1b commit 95567a4

20 files changed

Lines changed: 2337 additions & 104 deletions

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ RUN gleam build
2626
# build tailwind styles
2727
RUN npm run tailwind:build
2828

29+
# build client app
30+
RUN npm run client:build
31+
2932
# # build release
3033
RUN gleam export erlang-shipment
3134

client/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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>LTI Example Tool</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

client/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"private": true,
3+
"name": "lti-example-tool-client",
4+
"version": "0.0.1"
5+
}

client/src/App.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useEffect, useState } from "react";
2+
3+
type UserDetails = {
4+
sub: string;
5+
name: string;
6+
email: string;
7+
issuer: string;
8+
audience: string;
9+
roles: string;
10+
context_title: string;
11+
};
12+
13+
type LoadState =
14+
| { kind: "loading" }
15+
| { kind: "error"; message: string }
16+
| { kind: "ready"; user: UserDetails };
17+
18+
export function App() {
19+
const [state, setState] = useState<LoadState>({ kind: "loading" });
20+
21+
useEffect(() => {
22+
const controller = new AbortController();
23+
24+
async function loadUserDetails() {
25+
try {
26+
const response = await fetch("/api/me", {
27+
method: "GET",
28+
credentials: "include",
29+
signal: controller.signal,
30+
headers: { Accept: "application/json" },
31+
});
32+
33+
if (!response.ok) {
34+
throw new Error(
35+
response.status === 401
36+
? "You are not authenticated for this launch session."
37+
: `Request failed with status ${response.status}`,
38+
);
39+
}
40+
41+
const user = (await response.json()) as UserDetails;
42+
setState({ kind: "ready", user });
43+
} catch (error) {
44+
if (error instanceof DOMException && error.name === "AbortError") {
45+
return;
46+
}
47+
48+
const message =
49+
error instanceof Error ? error.message : "Unknown request error";
50+
setState({ kind: "error", message });
51+
}
52+
}
53+
54+
void loadUserDetails();
55+
56+
return () => {
57+
controller.abort();
58+
};
59+
}, []);
60+
61+
return (
62+
<main className="mx-auto max-w-3xl px-4 py-8">
63+
<section className="rounded-lg border border-gray-300 bg-white p-6 shadow-sm">
64+
<h2 className="text-xl font-semibold text-gray-900">Launch Successful</h2>
65+
<p className="mt-2 text-sm text-gray-600">
66+
This view is rendered by a React client and authenticated with launch
67+
cookies.
68+
</p>
69+
70+
{state.kind === "loading" ? (
71+
<p className="mt-6 text-sm text-gray-600">Loading user details...</p>
72+
) : null}
73+
74+
{state.kind === "error" ? (
75+
<p className="mt-6 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
76+
{state.message}
77+
</p>
78+
) : null}
79+
80+
{state.kind === "ready" ? (
81+
<dl className="mt-6 grid gap-3 text-sm">
82+
<Detail label="User ID" value={state.user.sub} />
83+
<Detail label="Name" value={state.user.name} />
84+
<Detail label="Email" value={state.user.email} />
85+
<Detail label="Roles" value={state.user.roles} />
86+
<Detail label="Context" value={state.user.context_title} />
87+
<Detail label="Issuer" value={state.user.issuer} />
88+
<Detail label="Audience" value={state.user.audience} />
89+
</dl>
90+
) : null}
91+
</section>
92+
</main>
93+
);
94+
}
95+
96+
function Detail({ label, value }: { label: string; value: string }) {
97+
return (
98+
<div className="grid gap-1 border-b border-gray-100 pb-3 sm:grid-cols-[9rem_1fr] sm:gap-3">
99+
<dt className="font-medium text-gray-700">{label}</dt>
100+
<dd className="break-all text-gray-900">{value.length > 0 ? value : "-"}</dd>
101+
</div>
102+
);
103+
}

client/src/main.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { StrictMode } from "react";
2+
import { createRoot } from "react-dom/client";
3+
import { App } from "./App";
4+
5+
const rootElement = document.getElementById("root");
6+
7+
if (rootElement === null) {
8+
throw new Error("Missing #root element");
9+
}
10+
11+
createRoot(rootElement).render(
12+
<StrictMode>
13+
<App />
14+
</StrictMode>,
15+
);

client/tsconfig.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"module": "ESNext",
5+
"moduleResolution": "Bundler",
6+
"jsx": "react-jsx",
7+
"strict": true,
8+
"strictNullChecks": true,
9+
"noUncheckedIndexedAccess": true,
10+
"noEmit": true,
11+
"isolatedModules": true,
12+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
13+
"types": ["vite/client"]
14+
},
15+
"include": ["src"]
16+
}

client/vite.config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import path from "node:path";
2+
import { defineConfig } from "vite";
3+
import react from "@vitejs/plugin-react";
4+
5+
export default defineConfig({
6+
plugins: [react()],
7+
define: {
8+
"process.env.NODE_ENV": JSON.stringify("production"),
9+
},
10+
build: {
11+
outDir: path.resolve(__dirname, "../priv/static/client"),
12+
emptyOutDir: true,
13+
sourcemap: true,
14+
lib: {
15+
entry: path.resolve(__dirname, "src/main.tsx"),
16+
formats: ["es"],
17+
fileName: () => "client-app.js",
18+
},
19+
},
20+
});

0 commit comments

Comments
 (0)