@chrysb/alphaclaw 0.1.19 → 0.1.21

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.
@@ -1,6 +1,8 @@
1
1
  import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useState } from "https://esm.sh/preact/hooks";
2
3
  import htm from "https://esm.sh/htm";
3
4
  import { SecretInput } from "../secret-input.js";
5
+ import { isValidGithubRepoInput } from "./welcome-config.js";
4
6
 
5
7
  const html = htm.bind(h);
6
8
 
@@ -36,245 +38,267 @@ export const WelcomeFormStep = ({
36
38
  loading,
37
39
  allValid,
38
40
  handleSubmit,
39
- }) => html`
40
- <div class="flex items-center justify-between">
41
- <div>
42
- <h2 class="text-sm font-medium text-gray-200">${activeGroup.title}</h2>
43
- <p class="text-xs text-gray-500">${activeGroup.description}</p>
44
- </div>
45
- ${activeGroup.validate(vals, { hasAi })
46
- ? html`<span
47
- class="text-xs font-medium px-2 py-0.5 rounded-full bg-green-900/50 text-green-400"
48
- >✓</span
49
- >`
50
- : activeGroup.id !== "tools"
41
+ }) => {
42
+ const [repoTouched, setRepoTouched] = useState(false);
43
+
44
+ useEffect(() => {
45
+ if (activeGroup.id !== "github") {
46
+ setRepoTouched(false);
47
+ }
48
+ }, [activeGroup.id]);
49
+
50
+ return html`
51
+ <div class="flex items-center justify-between">
52
+ <div>
53
+ <h2 class="text-sm font-medium text-gray-200">${activeGroup.title}</h2>
54
+ <p class="text-xs text-gray-500">${activeGroup.description}</p>
55
+ </div>
56
+ ${activeGroup.validate(vals, { hasAi })
51
57
  ? html`<span
52
- class="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-900/50 text-yellow-400"
53
- >Required</span
58
+ class="text-xs font-medium px-2 py-0.5 rounded-full bg-green-900/50 text-green-400"
59
+ >✓</span
54
60
  >`
55
- : null}
56
- </div>
57
-
58
- ${activeGroup.id === "ai" &&
59
- html`
60
- <div class="space-y-1">
61
- <label class="text-xs font-medium text-gray-400">Model</label>
62
- <select
63
- value=${vals.MODEL_KEY || ""}
64
- onInput=${(e) => setValue("MODEL_KEY", e.target.value)}
65
- class="w-full bg-black/30 border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
66
- >
67
- <option value="">Select a model</option>
68
- ${modelOptions.map(
69
- (model) => html`
70
- <option value=${model.key}>${model.label || model.key}</option>
71
- `,
72
- )}
73
- </select>
74
- <p class="text-xs text-gray-600">
75
- ${modelsLoading
76
- ? "Loading model catalog..."
77
- : modelsError
78
- ? modelsError
79
- : ""}
80
- </p>
81
- ${canToggleFullCatalog &&
82
- html`
83
- <button
84
- type="button"
85
- onclick=${() => setShowAllModels((prev) => !prev)}
86
- class="text-xs text-gray-500 hover:text-gray-300"
87
- >
88
- ${showAllModels
89
- ? "Show recommended models"
90
- : "Show full model catalog"}
91
- </button>
92
- `}
61
+ : activeGroup.id !== "tools"
62
+ ? html`<span
63
+ class="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-900/50 text-yellow-400"
64
+ >Required</span
65
+ >`
66
+ : null}
93
67
  </div>
94
- `}
95
- ${activeGroup.id === "ai" &&
96
- selectedProvider === "openai-codex" &&
97
- html`
98
- <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
99
- <div class="flex items-center justify-between">
100
- <span class="text-xs text-gray-400">Codex OAuth</span>
101
- ${codexLoading
102
- ? html`<span class="text-xs text-gray-500">Checking...</span>`
103
- : codexStatus.connected
104
- ? html`<span class="text-xs text-green-400">Connected</span>`
105
- : html`<span class="text-xs text-yellow-400">Not connected</span>`}
106
- </div>
107
- <div class="flex gap-2">
108
- <button
109
- type="button"
110
- onclick=${startCodexAuth}
111
- class="text-xs font-medium px-3 py-1.5 rounded-lg ${codexStatus.connected
112
- ? "border border-border text-gray-300 hover:border-gray-500"
113
- : "ac-btn-cyan"}"
68
+
69
+ ${activeGroup.id === "ai" &&
70
+ html`
71
+ <div class="space-y-1">
72
+ <label class="text-xs font-medium text-gray-400">Model</label>
73
+ <select
74
+ value=${vals.MODEL_KEY || ""}
75
+ onInput=${(e) => setValue("MODEL_KEY", e.target.value)}
76
+ class="w-full bg-black/30 border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
114
77
  >
115
- ${codexStatus.connected ? "Reconnect Codex" : "Connect Codex OAuth"}
116
- </button>
117
- ${codexStatus.connected &&
78
+ <option value="">Select a model</option>
79
+ ${modelOptions.map(
80
+ (model) => html`
81
+ <option value=${model.key}>${model.label || model.key}</option>
82
+ `,
83
+ )}
84
+ </select>
85
+ <p class="text-xs text-gray-600">
86
+ ${modelsLoading
87
+ ? "Loading model catalog..."
88
+ : modelsError
89
+ ? modelsError
90
+ : ""}
91
+ </p>
92
+ ${canToggleFullCatalog &&
118
93
  html`
119
94
  <button
120
95
  type="button"
121
- onclick=${handleCodexDisconnect}
122
- class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500"
96
+ onclick=${() => setShowAllModels((prev) => !prev)}
97
+ class="text-xs text-gray-500 hover:text-gray-300"
123
98
  >
124
- Disconnect
99
+ ${showAllModels
100
+ ? "Show recommended models"
101
+ : "Show full model catalog"}
125
102
  </button>
126
103
  `}
127
104
  </div>
128
- ${!codexStatus.connected &&
129
- codexAuthStarted &&
130
- html`
131
- <div class="space-y-1 pt-1">
132
- <p class="text-xs text-gray-500">
133
- ${codexAuthWaiting
134
- ? "Complete login in the popup, then paste the full redirect URL from the address bar (starts with "
135
- : "Paste the full redirect URL from the address bar (starts with "}
136
- <code class="text-xs bg-black/30 px-1 rounded"
137
- >http://localhost:1455/auth/callback</code
138
- >) ${codexAuthWaiting ? " to finish setup." : " to finish setup."}
139
- </p>
140
- <input
141
- type="text"
142
- value=${codexManualInput}
143
- onInput=${(e) => setCodexManualInput(e.target.value)}
144
- placeholder="http://localhost:1455/auth/callback?code=...&state=..."
145
- class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 outline-none focus:border-gray-500"
146
- />
105
+ `}
106
+ ${activeGroup.id === "ai" &&
107
+ selectedProvider === "openai-codex" &&
108
+ html`
109
+ <div class="bg-black/20 border border-border rounded-lg p-3 space-y-2">
110
+ <div class="flex items-center justify-between">
111
+ <span class="text-xs text-gray-400">Codex OAuth</span>
112
+ ${codexLoading
113
+ ? html`<span class="text-xs text-gray-500">Checking...</span>`
114
+ : codexStatus.connected
115
+ ? html`<span class="text-xs text-green-400">Connected</span>`
116
+ : html`<span class="text-xs text-yellow-400"
117
+ >Not connected</span
118
+ >`}
119
+ </div>
120
+ <div class="flex gap-2">
147
121
  <button
148
122
  type="button"
149
- onclick=${completeCodexAuth}
150
- disabled=${!codexManualInput.trim() || codexExchanging}
151
- class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
123
+ onclick=${startCodexAuth}
124
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ${codexStatus.connected
125
+ ? "border border-border text-gray-300 hover:border-gray-500"
126
+ : "ac-btn-cyan"}"
152
127
  >
153
- ${codexExchanging ? "Completing..." : "Complete Codex OAuth"}
128
+ ${codexStatus.connected ? "Reconnect Codex" : "Connect Codex OAuth"}
154
129
  </button>
130
+ ${codexStatus.connected &&
131
+ html`
132
+ <button
133
+ type="button"
134
+ onclick=${handleCodexDisconnect}
135
+ class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500"
136
+ >
137
+ Disconnect
138
+ </button>
139
+ `}
155
140
  </div>
156
- `}
157
- </div>
158
- `}
159
- ${(activeGroup.id === "ai"
160
- ? activeGroup.fields.filter((field) => visibleAiFieldKeys.has(field.key))
161
- : activeGroup.fields
162
- ).map(
163
- (field) => html`
164
- <div class="space-y-1" key=${field.key}>
165
- <label class="text-xs font-medium text-gray-400">${field.label}</label>
166
- <${SecretInput}
167
- key=${field.key}
168
- value=${vals[field.key] || ""}
169
- onInput=${(e) => setValue(field.key, e.target.value)}
170
- placeholder=${field.placeholder || ""}
171
- isSecret=${!field.isText}
172
- inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
173
- />
174
- <p class="text-xs text-gray-600">${field.hint}</p>
141
+ ${!codexStatus.connected &&
142
+ codexAuthStarted &&
143
+ html`
144
+ <div class="space-y-1 pt-1">
145
+ <p class="text-xs text-gray-500">
146
+ ${codexAuthWaiting
147
+ ? "Complete login in the popup, then paste the full redirect URL from the address bar (starts with "
148
+ : "Paste the full redirect URL from the address bar (starts with "}
149
+ <code class="text-xs bg-black/30 px-1 rounded"
150
+ >http://localhost:1455/auth/callback</code
151
+ >) ${codexAuthWaiting ? " to finish setup." : " to finish setup."}
152
+ </p>
153
+ <input
154
+ type="text"
155
+ value=${codexManualInput}
156
+ onInput=${(e) => setCodexManualInput(e.target.value)}
157
+ placeholder="http://localhost:1455/auth/callback?code=...&state=..."
158
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 outline-none focus:border-gray-500"
159
+ />
160
+ <button
161
+ type="button"
162
+ onclick=${completeCodexAuth}
163
+ disabled=${!codexManualInput.trim() || codexExchanging}
164
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-cyan"
165
+ >
166
+ ${codexExchanging ? "Completing..." : "Complete Codex OAuth"}
167
+ </button>
168
+ </div>
169
+ `}
175
170
  </div>
176
- `,
177
- )}
178
- ${error
179
- ? html`<div
180
- class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
181
- >
182
- ${error}
183
- </div>`
184
- : null}
185
- ${step === totalGroups - 1 && (!vals.OPENAI_API_KEY || !vals.GEMINI_API_KEY)
186
- ? html`
187
- ${!vals.OPENAI_API_KEY
188
- ? html`<div class="space-y-1">
189
- <label class="text-xs font-medium text-gray-400"
190
- >OpenAI API Key</label
191
- >
192
- <${SecretInput}
193
- value=${vals.OPENAI_API_KEY || ""}
194
- onInput=${(e) => setValue("OPENAI_API_KEY", e.target.value)}
195
- placeholder="sk-..."
196
- isSecret=${true}
197
- inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
198
- />
199
- <p class="text-xs text-gray-600">
200
- Used for memory embeddings -${" "}
201
- <a
202
- href="https://platform.openai.com"
203
- target="_blank"
204
- class="hover:underline"
205
- style="color: var(--accent-link)"
206
- >get key</a
171
+ `}
172
+ ${(activeGroup.id === "ai"
173
+ ? activeGroup.fields.filter((field) => visibleAiFieldKeys.has(field.key))
174
+ : activeGroup.fields
175
+ ).map(
176
+ (field) => html`
177
+ <div class="space-y-1" key=${field.key}>
178
+ <label class="text-xs font-medium text-gray-400"
179
+ >${field.label}</label
180
+ >
181
+ <${SecretInput}
182
+ key=${field.key}
183
+ value=${vals[field.key] || ""}
184
+ onInput=${(e) => setValue(field.key, e.target.value)}
185
+ onBlur=${field.key === "GITHUB_WORKSPACE_REPO"
186
+ ? () => setRepoTouched(true)
187
+ : undefined}
188
+ placeholder=${field.placeholder || ""}
189
+ isSecret=${!field.isText}
190
+ inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
191
+ />
192
+ <p class="text-xs text-gray-600">${field.hint}</p>
193
+ </div>
194
+ `,
195
+ )}
196
+ ${activeGroup.id === "github" &&
197
+ repoTouched &&
198
+ vals.GITHUB_WORKSPACE_REPO &&
199
+ !isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO)
200
+ ? html`<div class="text-xs text-red-300">
201
+ Workspace Repo must be in
202
+ <code class="text-xs bg-black/30 px-1 rounded">owner/repo</code>
203
+ format.
204
+ </div>`
205
+ : null}
206
+ ${error
207
+ ? html`<div
208
+ class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
209
+ >
210
+ ${error}
211
+ </div>`
212
+ : null}
213
+ ${step === totalGroups - 1 && (!vals.OPENAI_API_KEY || !vals.GEMINI_API_KEY)
214
+ ? html`
215
+ ${!vals.OPENAI_API_KEY
216
+ ? html`<div class="space-y-1">
217
+ <label class="text-xs font-medium text-gray-400"
218
+ >OpenAI API Key</label
207
219
  >
208
- </p>
209
- </div>`
210
- : null}
211
- ${!vals.GEMINI_API_KEY
212
- ? html`<div class="space-y-1">
213
- <label class="text-xs font-medium text-gray-400"
214
- >Gemini API Key</label
215
- >
216
- <${SecretInput}
217
- value=${vals.GEMINI_API_KEY || ""}
218
- onInput=${(e) => setValue("GEMINI_API_KEY", e.target.value)}
219
- placeholder="AI..."
220
- isSecret=${true}
221
- inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
222
- />
223
- <p class="text-xs text-gray-600">
224
- Used for memory embeddings and Nano Banana -${" "}
225
- <a
226
- href="https://aistudio.google.com"
227
- target="_blank"
228
- class="hover:underline"
229
- style="color: var(--accent-link)"
230
- >get key</a
220
+ <${SecretInput}
221
+ value=${vals.OPENAI_API_KEY || ""}
222
+ onInput=${(e) => setValue("OPENAI_API_KEY", e.target.value)}
223
+ placeholder="sk-..."
224
+ isSecret=${true}
225
+ inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
226
+ />
227
+ <p class="text-xs text-gray-600">
228
+ Used for memory embeddings -${" "}
229
+ <a
230
+ href="https://platform.openai.com"
231
+ target="_blank"
232
+ class="hover:underline"
233
+ style="color: var(--accent-link)"
234
+ >get key</a
235
+ >
236
+ </p>
237
+ </div>`
238
+ : null}
239
+ ${!vals.GEMINI_API_KEY
240
+ ? html`<div class="space-y-1">
241
+ <label class="text-xs font-medium text-gray-400"
242
+ >Gemini API Key</label
231
243
  >
232
- </p>
233
- </div>`
234
- : null}
235
- `
236
- : null}
237
-
238
- <div class="grid grid-cols-2 gap-2 pt-3">
239
- ${step < totalGroups - 1
240
- ? html`
241
- ${step > 0
242
- ? html`<button
243
- onclick=${goBack}
244
- class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all border border-border text-gray-300 hover:border-gray-500"
245
- >
246
- Back
247
- </button>`
248
- : html`<div class="w-full"></div>`}
249
- <button
250
- onclick=${goNext}
251
- disabled=${!currentGroupValid}
252
- class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ${currentGroupValid
253
- ? "bg-white text-black hover:opacity-85"
254
- : "bg-gray-800 text-gray-500 cursor-not-allowed"}"
255
- >
256
- Next
257
- </button>
244
+ <${SecretInput}
245
+ value=${vals.GEMINI_API_KEY || ""}
246
+ onInput=${(e) => setValue("GEMINI_API_KEY", e.target.value)}
247
+ placeholder="AI..."
248
+ isSecret=${true}
249
+ inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
250
+ />
251
+ <p class="text-xs text-gray-600">
252
+ Used for memory embeddings and Nano Banana -${" "}
253
+ <a
254
+ href="https://aistudio.google.com"
255
+ target="_blank"
256
+ class="hover:underline"
257
+ style="color: var(--accent-link)"
258
+ >get key</a
259
+ >
260
+ </p>
261
+ </div>`
262
+ : null}
258
263
  `
259
- : html`
260
- ${step > 0
261
- ? html`<button
262
- onclick=${goBack}
263
- class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all border border-border text-gray-300 hover:border-gray-500"
264
- >
265
- Back
266
- </button>`
267
- : html`<div class="w-full"></div>`}
268
- <button
269
- onclick=${handleSubmit}
270
- disabled=${!allValid || loading}
271
- class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ${allValid &&
272
- !loading
273
- ? "bg-white text-black hover:opacity-85"
274
- : "bg-gray-800 text-gray-500 cursor-not-allowed"}"
275
- >
276
- ${loading ? "Starting..." : "Complete Setup"}
277
- </button>
278
- `}
279
- </div>
280
- `;
264
+ : null}
265
+
266
+ <div class="grid grid-cols-2 gap-2 pt-3">
267
+ ${step < totalGroups - 1
268
+ ? html`
269
+ ${step > 0
270
+ ? html`<button
271
+ onclick=${goBack}
272
+ class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all border border-border text-gray-300 hover:border-gray-500"
273
+ >
274
+ Back
275
+ </button>`
276
+ : html`<div class="w-full"></div>`}
277
+ <button
278
+ onclick=${goNext}
279
+ disabled=${!currentGroupValid}
280
+ class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan"
281
+ >
282
+ Next
283
+ </button>
284
+ `
285
+ : html`
286
+ ${step > 0
287
+ ? html`<button
288
+ onclick=${goBack}
289
+ class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all border border-border text-gray-300 hover:border-gray-500"
290
+ >
291
+ Back
292
+ </button>`
293
+ : html`<div class="w-full"></div>`}
294
+ <button
295
+ onclick=${handleSubmit}
296
+ disabled=${!allValid || loading}
297
+ class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan"
298
+ >
299
+ ${loading ? "Starting..." : "Next"}
300
+ </button>
301
+ `}
302
+ </div>
303
+ `;
304
+ };
@@ -7,51 +7,62 @@ export const WelcomeHeader = ({
7
7
  groups,
8
8
  step,
9
9
  isSetupStep,
10
+ isPairingStep,
10
11
  stepNumber,
11
12
  activeStepLabel,
12
- vals,
13
- hasAi,
14
13
  }) => {
15
- const progressSteps = [...groups, { id: "setup", title: "Initializing" }];
14
+ const progressSteps = [
15
+ ...groups,
16
+ { id: "setup", title: "Initializing" },
17
+ { id: "pairing", title: "Pairing" },
18
+ ];
16
19
 
17
20
  return html`
18
- <div class="text-center mb-1">
19
- <img
20
- src="./img/logo.svg"
21
- alt="alphaclaw"
22
- class="mx-auto mb-3"
23
- width="32"
24
- height="33"
25
- />
26
- <h1 class="text-2xl font-semibold mb-2">Setup</h1>
27
- <p style="color: var(--text-muted)" class="text-sm">
28
- Let's get your agent running
29
- </p>
30
- <p class="text-xs my-2" style="color: var(--text-dim)">
31
- Step ${stepNumber} of ${progressSteps.length} - ${activeStepLabel}
32
- </p>
33
- </div>
21
+ <div class="text-center mb-1">
22
+ <img
23
+ src="./img/logo.svg"
24
+ alt="alphaclaw"
25
+ class="mx-auto mb-3"
26
+ width="32"
27
+ height="33"
28
+ />
29
+ <h1 class="text-2xl font-semibold mb-2">Setup</h1>
30
+ <p style="color: var(--text-muted)" class="text-sm">
31
+ Let's get your agent running
32
+ </p>
33
+ <div class="mt-4 mb-2 flex items-center justify-center">
34
+ <span
35
+ class="text-[11px] px-2.5 py-1 rounded-full border border-border font-medium"
36
+ style="background: rgba(0, 0, 0, 0.3); color: var(--text-muted)"
37
+ >
38
+ Step ${stepNumber} of ${progressSteps.length} - ${activeStepLabel}
39
+ </span>
40
+ </div>
41
+ </div>
34
42
 
35
- <div class="flex items-center gap-2">
36
- ${progressSteps.map((group, idx) => {
37
- const isFinalStep = idx === progressSteps.length - 1;
38
- const isActive = idx === step;
39
- const isComplete = isFinalStep
40
- ? isSetupStep
41
- : idx < step && group.validate(vals, { hasAi });
42
- const bg = isActive
43
- ? "rgba(99, 235, 255, 0.9)"
44
- : isComplete
45
- ? "rgba(99, 235, 255, 0.55)"
46
- : "rgba(82, 94, 122, 0.45)";
47
- return html`
48
- <div
49
- class="h-1 flex-1 rounded-full transition-colors"
50
- style=${{ background: bg }}
51
- title=${group.title}
52
- ></div>
53
- `;
54
- })}
55
- </div>
56
- `;
43
+ <div class="flex items-center gap-2">
44
+ ${progressSteps.map((group, idx) => {
45
+ const isActive = idx === step;
46
+ const isComplete = idx < step || (isSetupStep && group.id === "setup");
47
+ const isPairingComplete =
48
+ idx < step || (isPairingStep && group.id === "pairing");
49
+ const bg = isActive
50
+ ? "rgba(99, 235, 255, 0.9)"
51
+ : group.id === "pairing"
52
+ ? isPairingComplete
53
+ ? "rgba(99, 235, 255, 0.55)"
54
+ : "rgba(82, 94, 122, 0.45)"
55
+ : isComplete
56
+ ? "rgba(99, 235, 255, 0.55)"
57
+ : "rgba(82, 94, 122, 0.45)";
58
+ return html`
59
+ <div
60
+ class="h-1 flex-1 rounded-full transition-colors"
61
+ style=${{ background: bg }}
62
+ title=${group.title}
63
+ ></div>
64
+ `;
65
+ })}
66
+ </div>
67
+ `;
57
68
  };