@edpire/sdk 0.6.10 → 0.6.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/bin/create.js +2 -1
- package/dist/index.mjs +7 -7
- package/dist/react.mjs +8 -8
- package/dist/umd/index.global.js +96 -96
- package/examples/nextjs/.env.example +3 -5
- package/examples/nextjs/README.md +76 -66
- package/examples/nextjs/app/api/edpire/assessments/route.ts +36 -0
- package/examples/nextjs/app/api/edpire/token/route.ts +16 -23
- package/examples/nextjs/app/layout.tsx +1 -1
- package/examples/nextjs/app/page.tsx +113 -34
- package/examples/nextjs/docs/screenshots/01-pick.jpg +0 -0
- package/examples/nextjs/docs/screenshots/02-play.jpg +0 -0
- package/examples/nextjs/docs/screenshots/03-graded.jpg +0 -0
- package/examples/nextjs/next.config.ts +3 -2
- package/examples/nextjs/package.json +2 -2
- package/examples/vite-express/.env.example +4 -7
- package/examples/vite-express/README.md +89 -69
- package/examples/vite-express/docs/screenshots/01-pick.jpg +0 -0
- package/examples/vite-express/docs/screenshots/02-play.jpg +0 -0
- package/examples/vite-express/docs/screenshots/03-graded.jpg +0 -0
- package/examples/vite-express/index.html +1 -1
- package/examples/vite-express/package.json +4 -6
- package/examples/vite-express/server.ts +102 -96
- package/examples/vite-express/src/App.tsx +128 -56
- package/examples/vite-express/tsconfig.server.json +6 -6
- package/package.json +1 -1
- package/examples/nextjs/app/globals.css +0 -9
- /package/dist/{dist-UCCG7PHU.mjs → dist-WDU5QOZQ.mjs} +0 -0
|
Binary file
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>Edpire
|
|
6
|
+
<title>Edpire Vite and Express example</title>
|
|
7
7
|
<style>
|
|
8
8
|
* { box-sizing: border-box; }
|
|
9
9
|
body { margin: 0; }
|
|
@@ -5,19 +5,17 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "concurrently -k -n SERVER,VITE -c cyan,magenta \"tsx server.ts\" \"vite\"",
|
|
8
|
-
"build": "vite build && tsc -p tsconfig.server.json",
|
|
8
|
+
"build": "vite build && tsc --noEmit -p tsconfig.server.json",
|
|
9
9
|
"preview": "tsx server.ts",
|
|
10
|
-
"type-check": "tsc --noEmit"
|
|
10
|
+
"type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.server.json"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@edpire/sdk": "^0.6.
|
|
14
|
-
"cors": "^2.8.5",
|
|
13
|
+
"@edpire/sdk": "^0.6.10",
|
|
15
14
|
"express": "^4.18.2",
|
|
16
15
|
"react": "^19.0.0",
|
|
17
16
|
"react-dom": "^19.0.0"
|
|
18
17
|
},
|
|
19
18
|
"devDependencies": {
|
|
20
|
-
"@types/cors": "^2.8.17",
|
|
21
19
|
"@types/express": "^4.17.21",
|
|
22
20
|
"@types/node": "^20.0.0",
|
|
23
21
|
"@types/react": "^19.0.0",
|
|
@@ -27,6 +25,6 @@
|
|
|
27
25
|
"dotenv": "^16.3.1",
|
|
28
26
|
"tsx": "^4.7.0",
|
|
29
27
|
"typescript": "^5.0.0",
|
|
30
|
-
"vite": "^
|
|
28
|
+
"vite": "^6.0.0"
|
|
31
29
|
}
|
|
32
30
|
}
|
|
@@ -1,96 +1,102 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Express
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* /api
|
|
6
|
-
*
|
|
7
|
-
* In
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The Express side: two routes, plus static files in production.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/edpire/assessments what is published, so the page can list it
|
|
5
|
+
* POST /api/edpire/token a short-lived token for one learner
|
|
6
|
+
*
|
|
7
|
+
* In development Vite serves the page on 5173 and proxies /api here, so the
|
|
8
|
+
* browser only ever talks to one origin and no CORS is involved.
|
|
9
|
+
*
|
|
10
|
+
* npm run dev both, together
|
|
11
|
+
* npm run build && npm run preview
|
|
12
|
+
*/
|
|
13
|
+
import "dotenv/config"
|
|
14
|
+
import express from "express"
|
|
15
|
+
import path from "node:path"
|
|
16
|
+
import { fileURLToPath } from "node:url"
|
|
17
|
+
import { createEdpireTokenHandler, toNodeHandler, EdpireClient } from "@edpire/sdk/client"
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
20
|
+
const PORT = Number(process.env.PORT ?? 3001)
|
|
21
|
+
const isDev = process.env.NODE_ENV !== "production"
|
|
22
|
+
|
|
23
|
+
const app = express()
|
|
24
|
+
const edpire = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The assessments this learner can play.
|
|
28
|
+
*
|
|
29
|
+
* Your API key is used here, on the server. The browser receives only titles
|
|
30
|
+
* and IDs, neither of which is secret. `status=published` matters: a draft
|
|
31
|
+
* cannot be played.
|
|
32
|
+
*/
|
|
33
|
+
app.get("/api/edpire/assessments", async (_req, res) => {
|
|
34
|
+
try {
|
|
35
|
+
const { items } = await edpire.getAssessments({ status: "published", limit: 5 })
|
|
36
|
+
res.json({
|
|
37
|
+
assessments: items.map((a) => ({
|
|
38
|
+
id: a.id,
|
|
39
|
+
title: a.title,
|
|
40
|
+
exerciseCount: a.exercise_count ?? null,
|
|
41
|
+
})),
|
|
42
|
+
})
|
|
43
|
+
} catch (err) {
|
|
44
|
+
// "fetch failed" on its own tells the reader nothing, so name the two
|
|
45
|
+
// things it is almost always caused by.
|
|
46
|
+
res.status(502).json({
|
|
47
|
+
error:
|
|
48
|
+
`Could not list assessments: ${err instanceof Error ? err.message : err}. ` +
|
|
49
|
+
"Check EDPIRE_API_KEY, and that this machine can reach edpire.com.",
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* toNodeHandler adapts the Web-standard handler to Express's (req, res).
|
|
56
|
+
* Without it the request hangs until the browser times out.
|
|
57
|
+
*
|
|
58
|
+
* Mount it BEFORE any body parser. The handler reads the request stream
|
|
59
|
+
* itself, and a parser that already drained it makes this hang too.
|
|
60
|
+
*/
|
|
61
|
+
app.post(
|
|
62
|
+
"/api/edpire/token",
|
|
63
|
+
toNodeHandler(
|
|
64
|
+
createEdpireTokenHandler({
|
|
65
|
+
apiKey: process.env.EDPIRE_API_KEY!,
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
69
|
+
* REPLACE THIS. It decides whose results get recorded.
|
|
70
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
71
|
+
*
|
|
72
|
+
* `req` is a Web-standard Request rebuilt from the Node request, so
|
|
73
|
+
* Express extras like `req.session` and `req.cookies` do NOT exist here.
|
|
74
|
+
* Read the raw header and do your own lookup:
|
|
75
|
+
*
|
|
76
|
+
* const sid = parseCookie(req.headers.get("cookie"), "sid")
|
|
77
|
+
* return sid ? (await sessionStore.get(sid))?.userId ?? null : null
|
|
78
|
+
*
|
|
79
|
+
* Returning null rejects the request with 401 and mints nothing.
|
|
80
|
+
*
|
|
81
|
+
* Never read the learner from the request body. If the browser can name
|
|
82
|
+
* the learner, any learner can submit results as any other.
|
|
83
|
+
*/
|
|
84
|
+
resolveLearner: async () => "demo-learner-001",
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
// Production only: serve the built page, with an SPA fallback for client routes.
|
|
90
|
+
if (!isDev) {
|
|
91
|
+
const dist = path.join(__dirname, "dist")
|
|
92
|
+
app.use(express.static(dist))
|
|
93
|
+
app.get(/^(?!\/api).*$/, (_req, res) => res.sendFile(path.join(dist, "index.html")))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
app.listen(PORT, () => {
|
|
97
|
+
console.log(
|
|
98
|
+
isDev
|
|
99
|
+
? `[server] ready on :${PORT}. Vite serves the page on :5173 and proxies /api here.`
|
|
100
|
+
: `[server] ready on http://localhost:${PORT}`,
|
|
101
|
+
)
|
|
102
|
+
})
|
|
@@ -1,56 +1,128 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
})
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The whole integration, in two steps.
|
|
3
|
+
*
|
|
4
|
+
* 1. Ask your own server what is published, and show the titles.
|
|
5
|
+
* 2. Render <EdpireAssessmentPlayer> for whichever the learner picks. It fetches
|
|
6
|
+
* a token from /api/edpire/token, loads the assessment, grades the
|
|
7
|
+
* submission and shows per-question feedback.
|
|
8
|
+
*
|
|
9
|
+
* Your API key never reaches the browser.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useState } from "react"
|
|
12
|
+
import { EdpireAssessmentPlayer } from "@edpire/sdk/react"
|
|
13
|
+
|
|
14
|
+
type Assessment = { id: string; title: string; exerciseCount: number | null }
|
|
15
|
+
|
|
16
|
+
export default function App() {
|
|
17
|
+
const [assessments, setAssessments] = useState<Assessment[] | null>(null)
|
|
18
|
+
const [error, setError] = useState<string | null>(null)
|
|
19
|
+
const [playing, setPlaying] = useState<string | null>(null)
|
|
20
|
+
|
|
21
|
+
function load() {
|
|
22
|
+
setError(null)
|
|
23
|
+
setAssessments(null)
|
|
24
|
+
fetch("/api/edpire/assessments")
|
|
25
|
+
.then((r) => r.json())
|
|
26
|
+
.then((data) => (data.error ? setError(data.error) : setAssessments(data.assessments)))
|
|
27
|
+
.catch((e) => setError(String(e)))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
useEffect(load, [])
|
|
31
|
+
|
|
32
|
+
if (playing) {
|
|
33
|
+
return (
|
|
34
|
+
<EdpireAssessmentPlayer
|
|
35
|
+
tokenEndpoint="/api/edpire/token"
|
|
36
|
+
assessmentId={playing}
|
|
37
|
+
onComplete={(result) => {
|
|
38
|
+
// A UI hint. Record the authoritative result server-side from the
|
|
39
|
+
// submission.graded webhook.
|
|
40
|
+
console.log("complete", result.score, "/", result.max_score)
|
|
41
|
+
}}
|
|
42
|
+
onError={(err) => setError(`${err.code}: ${err.message}`)}
|
|
43
|
+
// The player fills its container, so the container needs a real height.
|
|
44
|
+
// Without one it collapses and the player renders invisibly.
|
|
45
|
+
style={{ width: "100%", height: "100vh" }}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<main style={styles.page}>
|
|
52
|
+
<h1 style={styles.title}>Edpire example</h1>
|
|
53
|
+
|
|
54
|
+
{error ? (
|
|
55
|
+
<>
|
|
56
|
+
<p style={styles.error}>{error}</p>
|
|
57
|
+
<button onClick={load} style={styles.retry}>Try again</button>
|
|
58
|
+
</>
|
|
59
|
+
) : assessments === null ? (
|
|
60
|
+
<p style={styles.muted}>Loading...</p>
|
|
61
|
+
) : assessments.length === 0 ? (
|
|
62
|
+
<Empty />
|
|
63
|
+
) : (
|
|
64
|
+
<>
|
|
65
|
+
<p style={styles.muted}>Published in your organisation. Choose one to play it.</p>
|
|
66
|
+
{assessments.map((a) => (
|
|
67
|
+
<button key={a.id} onClick={() => setPlaying(a.id)} style={styles.card}>
|
|
68
|
+
<span style={styles.cardTitle}>{a.title}</span>
|
|
69
|
+
{a.exerciseCount !== null && (
|
|
70
|
+
<span style={styles.cardMeta}>{a.exerciseCount} exercise(s)</span>
|
|
71
|
+
)}
|
|
72
|
+
</button>
|
|
73
|
+
))}
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
</main>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A new organisation has nothing published, and the fix is in Edpire. */
|
|
81
|
+
function Empty() {
|
|
82
|
+
return (
|
|
83
|
+
<div style={styles.empty}>
|
|
84
|
+
<p>
|
|
85
|
+
Nothing is published in this organisation yet, so there is nothing to open. Create an
|
|
86
|
+
assessment in Edpire, publish it, then reload. Drafts do not appear here.
|
|
87
|
+
</p>
|
|
88
|
+
<a href="https://docs.edpire.com/quickstart" style={styles.link}>
|
|
89
|
+
docs.edpire.com/quickstart
|
|
90
|
+
</a>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const styles: Record<string, React.CSSProperties> = {
|
|
96
|
+
page: { maxWidth: 640, margin: "0 auto", padding: 32, fontFamily: "system-ui, sans-serif" },
|
|
97
|
+
title: { fontSize: 24, marginBottom: 4 },
|
|
98
|
+
muted: { color: "#565b66", fontSize: 14 },
|
|
99
|
+
error: { color: "#d23b2b", fontSize: 14, whiteSpace: "pre-wrap" },
|
|
100
|
+
card: {
|
|
101
|
+
display: "flex",
|
|
102
|
+
flexDirection: "column",
|
|
103
|
+
alignItems: "flex-start",
|
|
104
|
+
gap: 4,
|
|
105
|
+
width: "100%",
|
|
106
|
+
padding: 16,
|
|
107
|
+
marginTop: 10,
|
|
108
|
+
border: "1px solid #e7e5e0",
|
|
109
|
+
borderRadius: 12,
|
|
110
|
+
background: "#fff",
|
|
111
|
+
cursor: "pointer",
|
|
112
|
+
textAlign: "left",
|
|
113
|
+
font: "inherit",
|
|
114
|
+
},
|
|
115
|
+
cardTitle: { fontSize: 15, fontWeight: 600 },
|
|
116
|
+
cardMeta: { fontSize: 12, color: "#8d93a0" },
|
|
117
|
+
empty: { marginTop: 12, color: "#565b66", fontSize: 14, lineHeight: 1.6 },
|
|
118
|
+
retry: {
|
|
119
|
+
marginTop: 12,
|
|
120
|
+
padding: "8px 18px",
|
|
121
|
+
border: "1px solid #e7e5e0",
|
|
122
|
+
borderRadius: 999,
|
|
123
|
+
background: "#fff",
|
|
124
|
+
cursor: "pointer",
|
|
125
|
+
font: "inherit",
|
|
126
|
+
},
|
|
127
|
+
link: { color: "#d23b2b" },
|
|
128
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
+
// tsconfig.json only includes src/, so server.ts would otherwise never be
|
|
3
|
+
// type-checked. The server is run by tsx, which strips types without
|
|
4
|
+
// checking them, so nothing else would catch a mistake in it.
|
|
2
5
|
"extends": "./tsconfig.json",
|
|
3
6
|
"compilerOptions": {
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"moduleResolution": "Bundler",
|
|
8
|
-
"target": "node18",
|
|
9
|
-
"allowImportingTsExtensions": false
|
|
7
|
+
"lib": ["ES2022"],
|
|
8
|
+
"target": "ES2022",
|
|
9
|
+
"types": ["node"]
|
|
10
10
|
},
|
|
11
11
|
"include": ["server.ts"]
|
|
12
12
|
}
|
package/package.json
CHANGED
|
File without changes
|