@learnpack/learnpack 5.0.355 → 5.0.357

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 (120) hide show
  1. package/lib/commands/serve.d.ts +0 -17
  2. package/lib/commands/serve.js +159 -129
  3. package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  4. package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
  5. package/lib/creatorDist/index.html +2 -2
  6. package/lib/models/creator.d.ts +1 -1
  7. package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
  8. package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
  9. package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
  10. package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
  11. package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
  12. package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
  13. package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
  14. package/lib/services/ingest/core/buildIngestConfig.js +40 -0
  15. package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
  16. package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
  17. package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
  18. package/lib/services/ingest/core/buildSidebar.js +28 -0
  19. package/lib/services/ingest/core/coursePaths.d.ts +31 -0
  20. package/lib/services/ingest/core/coursePaths.js +50 -0
  21. package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
  22. package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
  23. package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
  24. package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
  25. package/lib/services/ingest/core/pairLessons.d.ts +29 -0
  26. package/lib/services/ingest/core/pairLessons.js +102 -0
  27. package/lib/services/ingest/core/planWrites.d.ts +44 -0
  28. package/lib/services/ingest/core/planWrites.js +59 -0
  29. package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
  30. package/lib/services/ingest/core/resolveSlug.js +93 -0
  31. package/lib/services/ingest/core/types.d.ts +53 -0
  32. package/lib/services/ingest/core/types.js +2 -0
  33. package/lib/services/ingest/core/validatePackage.d.ts +38 -0
  34. package/lib/services/ingest/core/validatePackage.js +97 -0
  35. package/lib/services/ingest/createIngestService.d.ts +34 -0
  36. package/lib/services/ingest/createIngestService.js +38 -0
  37. package/lib/services/ingest/errors.d.ts +22 -0
  38. package/lib/services/ingest/errors.js +34 -0
  39. package/lib/services/ingest/http/errorMapping.d.ts +8 -0
  40. package/lib/services/ingest/http/errorMapping.js +40 -0
  41. package/lib/services/ingest/http/router.d.ts +32 -0
  42. package/lib/services/ingest/http/router.js +124 -0
  43. package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
  44. package/lib/services/ingest/http/zipPackageReader.js +62 -0
  45. package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
  46. package/lib/services/ingest/ports/courseStorage.js +2 -0
  47. package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
  48. package/lib/services/ingest/ports/packageRegistry.js +2 -0
  49. package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
  50. package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
  51. package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
  52. package/lib/utils/coursePackage/bucketIo.js +36 -0
  53. package/lib/utils/coursePackage/learnJson.d.ts +26 -0
  54. package/lib/utils/coursePackage/learnJson.js +36 -0
  55. package/lib/utils/coursePackage/sidebar.d.ts +15 -0
  56. package/lib/utils/coursePackage/sidebar.js +26 -0
  57. package/lib/utils/creatorSocket.js +15 -21
  58. package/lib/utils/gcpCredentials.d.ts +18 -0
  59. package/lib/utils/gcpCredentials.js +27 -0
  60. package/lib/utils/rigoActions.d.ts +16 -0
  61. package/lib/utils/rigoActions.js +102 -1
  62. package/lib/utils/socketRegistry.d.ts +46 -0
  63. package/lib/utils/socketRegistry.js +83 -0
  64. package/package.json +1 -1
  65. package/src/commands/serve.ts +162 -114
  66. package/src/creator/README.md +63 -51
  67. package/src/creator/package-lock.json +1188 -8
  68. package/src/creator/package.json +5 -1
  69. package/src/creator/src/App.tsx +120 -54
  70. package/src/creator/src/components/FileUploader.tsx +1 -12
  71. package/src/creator/src/components/NotificationListener.tsx +1 -8
  72. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
  73. package/src/creator/src/locales/en.json +8 -0
  74. package/src/creator/src/locales/es.json +8 -0
  75. package/src/creator/src/utils/completionResult.test.ts +144 -0
  76. package/src/creator/src/utils/completionResult.ts +154 -0
  77. package/src/creator/src/utils/constants.ts +17 -4
  78. package/src/creator/src/utils/socket.test.ts +113 -0
  79. package/src/creator/src/utils/socket.ts +60 -37
  80. package/src/creator/src/utils/store.ts +84 -67
  81. package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
  82. package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
  83. package/src/creator/vitest.config.ts +16 -0
  84. package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  85. package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
  86. package/src/creatorDist/index.html +2 -2
  87. package/src/models/creator.ts +1 -1
  88. package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
  89. package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
  90. package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
  91. package/src/services/ingest/core/buildIngestConfig.ts +51 -0
  92. package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
  93. package/src/services/ingest/core/buildSidebar.ts +32 -0
  94. package/src/services/ingest/core/coursePaths.ts +59 -0
  95. package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
  96. package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
  97. package/src/services/ingest/core/pairLessons.ts +132 -0
  98. package/src/services/ingest/core/planWrites.ts +91 -0
  99. package/src/services/ingest/core/resolveSlug.ts +119 -0
  100. package/src/services/ingest/core/types.ts +57 -0
  101. package/src/services/ingest/core/validatePackage.ts +132 -0
  102. package/src/services/ingest/createIngestService.ts +66 -0
  103. package/src/services/ingest/errors.ts +45 -0
  104. package/src/services/ingest/http/errorMapping.ts +49 -0
  105. package/src/services/ingest/http/router.ts +156 -0
  106. package/src/services/ingest/http/zipPackageReader.ts +81 -0
  107. package/src/services/ingest/ports/courseStorage.ts +25 -0
  108. package/src/services/ingest/ports/packageRegistry.ts +60 -0
  109. package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
  110. package/src/ui/_app/app.js +325 -325
  111. package/src/ui/app.tar.gz +0 -0
  112. package/src/utils/coursePackage/bucketIo.ts +46 -0
  113. package/src/utils/coursePackage/learnJson.ts +37 -0
  114. package/src/utils/coursePackage/sidebar.ts +28 -0
  115. package/src/utils/creatorSocket.ts +16 -22
  116. package/src/utils/gcpCredentials.ts +38 -0
  117. package/src/utils/rigoActions.ts +163 -0
  118. package/src/utils/socketRegistry.ts +85 -0
  119. package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
  120. package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Interpretation of the completion payloads Rigobot delivers over the socket.
3
+ *
4
+ * Course generation is asynchronous: `publicInteractiveCreation` gets a 201 back
5
+ * with a pending job, and the real outcome arrives later through the webhook the
6
+ * CLI relays. A 2xx on the initial POST therefore says nothing about success —
7
+ * the job can still fail against the LLM provider and reach us as
8
+ * `{ status: "ERROR", status_text: "...", parsed: null }`.
9
+ *
10
+ * Every consumer used to read `payload.parsed.listOfSteps` directly, which threw
11
+ * a TypeError inside the socket.io listener and left the UI on its loading
12
+ * screen forever. Everything now goes through `interpretCompletionPayload`, which
13
+ * never throws and always returns a branch the caller has to handle.
14
+ */
15
+
16
+ export type TCompletionErrorCode =
17
+ | "QUOTA_EXCEEDED"
18
+ | "RATE_LIMITED"
19
+ | "PROVIDER_UNAVAILABLE"
20
+ | "MALFORMED_RESPONSE"
21
+ | "TIMEOUT"
22
+ | "UNKNOWN";
23
+
24
+ /** The shape the syllabus screens consume, with every field guaranteed present. */
25
+ export type TSyllabusPayload = {
26
+ listOfSteps: string[];
27
+ title: string;
28
+ description: string;
29
+ languageCode: string;
30
+ technologies: string[];
31
+ aiMessage: string;
32
+ };
33
+
34
+ export type TCompletionResult =
35
+ | { ok: true; parsed: TSyllabusPayload }
36
+ | { ok: false; code: TCompletionErrorCode; detail: string };
37
+
38
+ /**
39
+ * i18n key per failure code.
40
+ *
41
+ * Declared as a total Record rather than a lookup with a fallback: adding a code
42
+ * to the union without deciding what the user is told then fails the build,
43
+ * instead of quietly rendering a raw key or an empty toast.
44
+ */
45
+ export const COMPLETION_ERROR_I18N_KEY: Record<TCompletionErrorCode, string> = {
46
+ QUOTA_EXCEEDED: "completionError.quotaExceeded",
47
+ RATE_LIMITED: "completionError.rateLimited",
48
+ PROVIDER_UNAVAILABLE: "completionError.providerUnavailable",
49
+ MALFORMED_RESPONSE: "completionError.malformedResponse",
50
+ TIMEOUT: "completionError.timeout",
51
+ UNKNOWN: "completionError.unknown",
52
+ };
53
+
54
+ /**
55
+ * Which failure a Rigobot `status_text` describes.
56
+ *
57
+ * The provider errors reach us as the string body of an OpenAI-compatible SDK
58
+ * exception, so matching is by substring and deliberately loose. The quota case
59
+ * is the one worth naming: it is not transient, and telling the user to retry
60
+ * would be wrong.
61
+ * @param statusText - The `status_text` field of an errored completion job.
62
+ * @returns The failure code the message describes.
63
+ */
64
+ const classifyStatusText = (statusText: string): TCompletionErrorCode => {
65
+ const text = statusText.toLowerCase();
66
+
67
+ if (
68
+ text.includes("permission-denied") ||
69
+ text.includes("credit") ||
70
+ text.includes("spending limit") ||
71
+ text.includes("quota")
72
+ ) {
73
+ return "QUOTA_EXCEEDED";
74
+ }
75
+
76
+ if (text.includes("rate limit") || text.includes("429")) {
77
+ return "RATE_LIMITED";
78
+ }
79
+
80
+ if (
81
+ text.includes("unavailable") ||
82
+ text.includes("timeout") ||
83
+ text.includes("timed out") ||
84
+ /\b5\d{2}\b/.test(text)
85
+ ) {
86
+ return "PROVIDER_UNAVAILABLE";
87
+ }
88
+
89
+ return "UNKNOWN";
90
+ };
91
+
92
+ const asString = (value: unknown): string =>
93
+ typeof value === "string" ? value : "";
94
+
95
+ const asStringArray = (value: unknown): string[] =>
96
+ Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
97
+
98
+ /**
99
+ * Turns a raw notification payload into a result the caller must branch on.
100
+ *
101
+ * Never throws: anything unrecognisable comes back as `MALFORMED_RESPONSE`
102
+ * rather than as an exception inside a socket listener, where nothing catches it.
103
+ * @param payload - Whatever arrived over the socket.
104
+ * @returns The parsed syllabus, or the failure to report.
105
+ */
106
+ export function interpretCompletionPayload(
107
+ payload: unknown
108
+ ): TCompletionResult {
109
+ if (typeof payload !== "object" || payload === null) {
110
+ return {
111
+ ok: false,
112
+ code: "MALFORMED_RESPONSE",
113
+ detail: `Expected an object, received ${typeof payload}`,
114
+ };
115
+ }
116
+
117
+ const job = payload as Record<string, unknown>;
118
+
119
+ if (job.status === "ERROR") {
120
+ const detail = asString(job.status_text) || "No status text provided";
121
+ return { ok: false, code: classifyStatusText(detail), detail };
122
+ }
123
+
124
+ const parsed = job.parsed;
125
+
126
+ // Also covers a SUCCESS job whose output degenerated: the syllabus screens
127
+ // cannot render without a list of steps, so it is a malformed response
128
+ // regardless of what the job status claims.
129
+ if (
130
+ typeof parsed !== "object" ||
131
+ parsed === null ||
132
+ !Array.isArray((parsed as Record<string, unknown>).listOfSteps)
133
+ ) {
134
+ return {
135
+ ok: false,
136
+ code: "MALFORMED_RESPONSE",
137
+ detail: "The completion carried no list of steps",
138
+ };
139
+ }
140
+
141
+ const fields = parsed as Record<string, unknown>;
142
+
143
+ return {
144
+ ok: true,
145
+ parsed: {
146
+ listOfSteps: asStringArray(fields.listOfSteps),
147
+ title: asString(fields.title),
148
+ description: asString(fields.description),
149
+ languageCode: asString(fields.languageCode),
150
+ technologies: asStringArray(fields.technologies),
151
+ aiMessage: asString(fields.aiMessage),
152
+ },
153
+ };
154
+ }
@@ -1,13 +1,26 @@
1
- export const DEV_MODE = false
1
+ export const DEV_MODE = false;
2
2
 
3
3
  export const RIGOBOT_HOST = DEV_MODE
4
4
  ? "https://rigobot-test-cca7d841c9d8.herokuapp.com"
5
5
  : // "https://8000-charlytoc-rigobot-bmwdeam7cev.ws-us120.gitpod.io"
6
- "https://rigobot.herokuapp.com"
6
+ "https://rigobot.herokuapp.com";
7
7
 
8
8
  export const BREATHECODE_HOST = DEV_MODE
9
9
  ? "https://breathecode-test.herokuapp.com"
10
- : "https://breathecode.herokuapp.com"
10
+ : "https://breathecode.herokuapp.com";
11
+
12
+ /**
13
+ * How long to wait for a completion notification before giving up.
14
+ *
15
+ * The trade-off runs both ways. Too long and a user whose notification is never
16
+ * coming stares at a spinner; too short and a slow but healthy job is reported
17
+ * as a failure, because giving up also unsubscribes — a result arriving after
18
+ * the deadline is lost even though the server produced it.
19
+ *
20
+ * A minute is the agreed ceiling. If timeouts start showing up on generations
21
+ * that actually succeed, raise this rather than making the user retry.
22
+ */
23
+ export const COMPLETION_TIMEOUT_MS = 60_000;
11
24
 
12
25
  export const RIGO_FLOAT_GIF =
13
- "https://raw.githubusercontent.com/learnpack/ide/20ed3f4c3ead9b33d5d6acb20154dcd93a0ec4af/public/rigo-float.gif"
26
+ "https://raw.githubusercontent.com/learnpack/ide/20ed3f4c3ead9b33d5d6acb20154dcd93a0ec4af/public/rigo-float.gif";
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => {
4
+ const created: Array<Record<string, ReturnType<typeof vi.fn>>> = [];
5
+
6
+ const io = vi.fn(() => {
7
+ const socket = {
8
+ on: vi.fn(),
9
+ off: vi.fn(),
10
+ emit: vi.fn(),
11
+ connect: vi.fn(),
12
+ disconnect: vi.fn(),
13
+ };
14
+ created.push(socket);
15
+ return socket;
16
+ });
17
+
18
+ return { io, created };
19
+ });
20
+
21
+ vi.mock("socket.io-client", () => ({ io: mocks.io }));
22
+
23
+ import CreatorSocket from "./socket";
24
+
25
+ const lastSocket = () => mocks.created[mocks.created.length - 1];
26
+
27
+ describe("CreatorSocket", () => {
28
+ beforeEach(() => {
29
+ mocks.io.mockClear();
30
+ mocks.created.length = 0;
31
+ });
32
+
33
+ it("opens one connection and reuses it for further subscribers", () => {
34
+ const client = new CreatorSocket("");
35
+
36
+ client.subscribe("notification-1", vi.fn());
37
+ client.subscribe("notification-2", vi.fn());
38
+
39
+ expect(mocks.io).toHaveBeenCalledTimes(1);
40
+ expect(client.subscriptionCount()).toBe(2);
41
+ });
42
+
43
+ it("registers the channel with the server", () => {
44
+ const client = new CreatorSocket("");
45
+ const callback = vi.fn();
46
+
47
+ client.subscribe("notification-1", callback);
48
+
49
+ expect(lastSocket().on).toHaveBeenCalledWith("notification-1", callback);
50
+ expect(lastSocket().emit).toHaveBeenCalledWith("registerNotification", {
51
+ notificationId: "notification-1",
52
+ });
53
+ });
54
+
55
+ // The bug: disconnect() used to close the shared socket for everybody, so
56
+ // unmounting one uploaded-file card killed the subscription the wizard was
57
+ // waiting on.
58
+ it("keeps the connection open while another subscriber remains", () => {
59
+ const client = new CreatorSocket("");
60
+ const releaseFirst = client.subscribe("notification-1", vi.fn());
61
+ client.subscribe("notification-2", vi.fn());
62
+
63
+ releaseFirst();
64
+
65
+ expect(lastSocket().off).toHaveBeenCalledTimes(1);
66
+ expect(lastSocket().disconnect).not.toHaveBeenCalled();
67
+ expect(client.subscriptionCount()).toBe(1);
68
+ });
69
+
70
+ it("closes the connection when the last subscriber leaves", () => {
71
+ const client = new CreatorSocket("");
72
+ const releaseFirst = client.subscribe("notification-1", vi.fn());
73
+ const releaseSecond = client.subscribe("notification-2", vi.fn());
74
+
75
+ releaseFirst();
76
+ releaseSecond();
77
+
78
+ expect(lastSocket().disconnect).toHaveBeenCalledTimes(1);
79
+ expect(client.subscriptionCount()).toBe(0);
80
+ });
81
+
82
+ it("ignores a repeated unsubscribe instead of miscounting", () => {
83
+ const client = new CreatorSocket("");
84
+ const release = client.subscribe("notification-1", vi.fn());
85
+ client.subscribe("notification-2", vi.fn());
86
+
87
+ release();
88
+ release();
89
+
90
+ expect(client.subscriptionCount()).toBe(1);
91
+ expect(lastSocket().disconnect).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it("opens a fresh connection after the previous one was closed", () => {
95
+ const client = new CreatorSocket("");
96
+
97
+ client.subscribe("notification-1", vi.fn())();
98
+ client.subscribe("notification-2", vi.fn());
99
+
100
+ expect(mocks.io).toHaveBeenCalledTimes(2);
101
+ expect(client.subscriptionCount()).toBe(1);
102
+ });
103
+
104
+ it("does not connect for an empty notification id", () => {
105
+ const client = new CreatorSocket("");
106
+
107
+ const release = client.subscribe("", vi.fn());
108
+
109
+ expect(mocks.io).not.toHaveBeenCalled();
110
+ expect(client.subscriptionCount()).toBe(0);
111
+ expect(() => release()).not.toThrow();
112
+ });
113
+ });
@@ -1,61 +1,84 @@
1
- import { io, Socket } from "socket.io-client"
1
+ import { io, Socket } from "socket.io-client";
2
2
 
3
- type EventCallback = (...args: any[]) => void
3
+ type EventCallback = (...args: any[]) => void;
4
4
 
5
+ /**
6
+ * A socket.io connection shared by every notification listener in the app.
7
+ *
8
+ * The connection is shared but its lifetime is not owned by any one caller,
9
+ * which the previous API got wrong: `disconnect()` tore down the socket for
10
+ * everybody, so unmounting one uploaded-file card killed the subscription the
11
+ * wizard was waiting on. Callers now get an unsubscribe function that only
12
+ * releases their own listener, and the socket closes when the last one goes.
13
+ */
5
14
  class CreatorSocket {
6
- private socket: Socket | null = null
7
- private readonly url: string
15
+ private socket: Socket | null = null;
16
+
17
+ private subscriptions = 0;
18
+
19
+ private readonly url: string;
8
20
 
9
21
  constructor(url: string) {
10
- this.url = url
22
+ this.url = url;
11
23
  }
12
24
 
13
25
  /**
14
- * Conecta manualmente al servidor de websockets
26
+ * Listens for one notification, connecting on the first subscriber.
27
+ * @param notificationId - Channel to join; also the event name the server emits.
28
+ * @param callback - Called with the notification payload.
29
+ * @returns Releases this subscription, and the socket once it is the last one.
15
30
  */
16
- connect() {
17
- if (this.socket) return
18
- this.socket = io(this.url, { autoConnect: false, path: "/sockete" })
19
- this.socket.connect()
20
- }
31
+ subscribe(notificationId: string, callback: EventCallback): () => void {
32
+ if (!notificationId) {
33
+ return () => {};
34
+ }
21
35
 
22
- /**
23
- * Desconecta del servidor
24
- */
25
- disconnect() {
26
- if (this.socket) {
27
- this.socket.disconnect()
28
- this.socket = null
36
+ if (!this.socket) {
37
+ this.socket = io(this.url, { autoConnect: false, path: "/sockete" });
38
+ this.socket.connect();
29
39
  }
30
- }
31
40
 
32
- /**
33
- * Verifica si está conectado
34
- */
35
- isConnected(): boolean {
36
- return !!this.socket?.connected
37
- }
41
+ // Captured so a late unsubscribe cannot act on a socket that has since been
42
+ // replaced by a newer connection.
43
+ const socket = this.socket;
38
44
 
39
- /**
40
- * Registra un evento personalizado
41
- */
42
- on(event: string, callback: EventCallback) {
43
- this.socket?.on(event, callback)
45
+ socket.on(notificationId, callback);
46
+ socket.emit("registerNotification", { notificationId });
47
+ this.subscriptions += 1;
48
+
49
+ let released = false;
50
+
51
+ return () => {
52
+ if (released) {
53
+ return;
54
+ }
55
+ released = true;
56
+
57
+ socket.off(notificationId, callback);
58
+ this.subscriptions -= 1;
59
+
60
+ if (this.subscriptions === 0 && this.socket === socket) {
61
+ socket.disconnect();
62
+ this.socket = null;
63
+ }
64
+ };
44
65
  }
45
66
 
46
67
  /**
47
- * Emite un evento al servidor
68
+ * Whether the shared connection is currently open.
69
+ * @returns True while connected.
48
70
  */
49
- emit(event: string, ...args: any[]) {
50
- this.socket?.emit(event, ...args)
71
+ isConnected(): boolean {
72
+ return Boolean(this.socket?.connected);
51
73
  }
52
74
 
53
75
  /**
54
- * Elimina un evento registrado
76
+ * How many live subscriptions the connection is serving.
77
+ * @returns The current subscriber count.
55
78
  */
56
- off(event: string, callback?: EventCallback) {
57
- this.socket?.off(event, callback)
79
+ subscriptionCount(): number {
80
+ return this.subscriptions;
58
81
  }
59
82
  }
60
83
 
61
- export default CreatorSocket
84
+ export default CreatorSocket;
@@ -1,77 +1,77 @@
1
- import { create } from "zustand"
2
- import { persist } from "zustand/middleware"
3
- import { Lesson } from "../components/LessonItem"
4
- import { ParsedFile } from "../components/FileUploader"
5
- import { TMessage } from "../components/Message"
1
+ import { create } from "zustand";
2
+ import { persist } from "zustand/middleware";
3
+ import { Lesson } from "../components/LessonItem";
4
+ import { ParsedFile } from "../components/FileUploader";
5
+ import { TMessage } from "../components/Message";
6
6
  // import { ParsedLink } from "../components/LinkUploader"
7
- export type TDifficulty = "easy" | "beginner" | "intermediate" | "hard"
7
+ export type TDifficulty = "easy" | "beginner" | "intermediate" | "hard";
8
8
 
9
9
  export type FormState = {
10
- description: string
11
- duration: number
12
- hasContentIndex: boolean
13
- contentIndex: string
14
- purpose: string
15
- difficulty: TDifficulty
16
- slug: string
17
- language?: string
18
- isCompleted: boolean
19
- variables: string[]
20
- currentStep: string
21
- title?: string
22
- technologies?: string[]
23
- }
10
+ description: string;
11
+ duration: number;
12
+ hasContentIndex: boolean;
13
+ contentIndex: string;
14
+ purpose: string;
15
+ difficulty: TDifficulty;
16
+ slug: string;
17
+ language?: string;
18
+ isCompleted: boolean;
19
+ variables: string[];
20
+ currentStep: string;
21
+ title?: string;
22
+ technologies?: string[];
23
+ };
24
24
 
25
25
  type Auth = {
26
- bcToken: string
27
- rigoToken: string
28
- userId: string
29
- user: any
30
- publicToken: string
31
- }
26
+ bcToken: string;
27
+ rigoToken: string;
28
+ userId: string;
29
+ user: any;
30
+ publicToken: string;
31
+ };
32
32
  export type Syllabus = {
33
- lessons: Lesson[]
34
- courseInfo: FormState
33
+ lessons: Lesson[];
34
+ courseInfo: FormState;
35
35
  // messages: TMessage[]
36
- }
36
+ };
37
37
 
38
38
  type Consumables = {
39
- [key: string]: number
40
- }
39
+ [key: string]: number;
40
+ };
41
41
 
42
42
  type TTechnology = {
43
- slug: string
44
- lang: string
45
- }
43
+ slug: string;
44
+ lang: string;
45
+ };
46
46
 
47
47
  type Store = {
48
- auth: Auth
49
- formState: FormState
50
- setFormState: (formState: Partial<FormState>) => void
51
- resetFormState: () => void
52
- setAuth: (auth: Auth) => void
48
+ auth: Auth;
49
+ formState: FormState;
50
+ setFormState: (formState: Partial<FormState>) => void;
51
+ resetFormState: () => void;
52
+ setAuth: (auth: Auth) => void;
53
53
  // syllabus: Syllabus
54
- planToRedirect: string
55
- setPlanToRedirect: (planToRedirect: string) => void
56
- uploadedFiles: ParsedFile[]
57
- setUploadedFiles: (uploadedFiles: ParsedFile[]) => void
58
- messages: TMessage[]
54
+ planToRedirect: string;
55
+ setPlanToRedirect: (planToRedirect: string) => void;
56
+ uploadedFiles: ParsedFile[];
57
+ setUploadedFiles: (uploadedFiles: ParsedFile[]) => void;
58
+ messages: TMessage[];
59
59
  setMessages: (
60
60
  messages: TMessage[] | ((prev: TMessage[]) => TMessage[])
61
- ) => void
62
- cleanHistory: () => void
63
- history: Syllabus[]
64
- technologies: TTechnology[]
65
- setTechnologies: (technologies: TTechnology[]) => void
66
- undo: () => void
67
- push: (syllabus: Syllabus) => void
68
- cleanAll: () => void
61
+ ) => void;
62
+ cleanHistory: () => void;
63
+ history: Syllabus[];
64
+ technologies: TTechnology[];
65
+ setTechnologies: (technologies: TTechnology[]) => void;
66
+ undo: () => void;
67
+ push: (syllabus: Syllabus) => void;
68
+ cleanAll: () => void;
69
69
  // setSyllabus: (syllabus: Partial<Syllabus>) => void
70
- consumables: Consumables
71
- setConsumables: (consumables: Partial<Consumables>) => void
72
- mode: "student" | "teacher"
73
- setMode: (mode: "student" | "teacher") => void
74
- }
70
+ consumables: Consumables;
71
+ setConsumables: (consumables: Partial<Consumables>) => void;
72
+ mode: "student" | "teacher";
73
+ setMode: (mode: "student" | "teacher") => void;
74
+ };
75
75
 
76
76
  const useStore = create<Store>()(
77
77
  persist(
@@ -117,7 +117,7 @@ const useStore = create<Store>()(
117
117
  typeof messages === "function"
118
118
  ? messages(state.messages)
119
119
  : messages,
120
- }))
120
+ }));
121
121
  },
122
122
  setFormState: (formState: Partial<FormState>) =>
123
123
  set((state) => ({ formState: { ...state.formState, ...formState } })),
@@ -154,18 +154,18 @@ const useStore = create<Store>()(
154
154
  push: (syllabus: Syllabus) => {
155
155
  set((state) => ({
156
156
  history: [...state.history, syllabus],
157
- }))
157
+ }));
158
158
  },
159
159
 
160
160
  undo: () => {
161
161
  set((state) => {
162
162
  return {
163
163
  history: state.history.slice(0, -1),
164
- }
165
- })
164
+ };
165
+ });
166
166
  },
167
167
  cleanHistory: () => {
168
- set(() => ({ history: [] }))
168
+ set(() => ({ history: [] }));
169
169
  },
170
170
  cleanAll: () => {
171
171
  set({
@@ -193,20 +193,20 @@ const useStore = create<Store>()(
193
193
  ],
194
194
  currentStep: "description",
195
195
  },
196
- })
196
+ });
197
197
  },
198
198
  consumables: {},
199
199
  setConsumables: (consumables: Partial<Consumables>) =>
200
200
  set((state) => {
201
201
  const sanitized: Consumables = Object.fromEntries(
202
202
  Object.entries(consumables).map(([k, v]) => [k, v ?? 0])
203
- )
203
+ );
204
204
  return {
205
205
  consumables: {
206
206
  ...state.consumables,
207
207
  ...sanitized,
208
208
  },
209
- }
209
+ };
210
210
  }),
211
211
 
212
212
  setAuth: (auth: Auth) => set({ auth }),
@@ -215,8 +215,25 @@ const useStore = create<Store>()(
215
215
  }),
216
216
  {
217
217
  name: "syllabus-storage",
218
+ /**
219
+ * Never resume a page load already claiming the wizard is finished.
220
+ *
221
+ * `formState.isCompleted` drives an effect in App.tsx that fires course
222
+ * generation. Persisting it meant that a generation which failed without
223
+ * clearing the flag came back on every reload and fired again, so the user
224
+ * could not escape the loading screen without wiping localStorage.
225
+ *
226
+ * Handled on rehydration rather than with `partialize` because that only
227
+ * governs what gets written: users whose storage already holds a stuck
228
+ * `true` need it cleared on the way in.
229
+ */
230
+ onRehydrateStorage: () => (state) => {
231
+ if (state?.formState.isCompleted) {
232
+ state.formState.isCompleted = false;
233
+ }
234
+ },
218
235
  }
219
236
  )
220
- )
237
+ );
221
238
 
222
- export default useStore
239
+ export default useStore;