create-interview-cockpit 0.1.0
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/README.md +62 -0
- package/index.js +302 -0
- package/package.json +44 -0
- package/template/.env.example +14 -0
- package/template/client/index.html +12 -0
- package/template/client/package-lock.json +6012 -0
- package/template/client/package.json +34 -0
- package/template/client/postcss.config.cjs +6 -0
- package/template/client/src/App.tsx +120 -0
- package/template/client/src/api.ts +132 -0
- package/template/client/src/components/AnnotationDialog.tsx +307 -0
- package/template/client/src/components/ChatMessage.tsx +89 -0
- package/template/client/src/components/ChatView.tsx +763 -0
- package/template/client/src/components/CodeContextPanel.tsx +470 -0
- package/template/client/src/components/FileAttachments.tsx +107 -0
- package/template/client/src/components/FileViewerModal.tsx +470 -0
- package/template/client/src/components/MarkdownRenderer.tsx +333 -0
- package/template/client/src/components/MermaidDiagram.tsx +157 -0
- package/template/client/src/components/Sidebar.tsx +419 -0
- package/template/client/src/components/TextAnnotator.tsx +476 -0
- package/template/client/src/index.css +61 -0
- package/template/client/src/main.tsx +10 -0
- package/template/client/src/store.ts +321 -0
- package/template/client/src/types.ts +65 -0
- package/template/client/src/vite-env.d.ts +1 -0
- package/template/client/tailwind.config.cjs +8 -0
- package/template/client/tsconfig.json +16 -0
- package/template/client/tsconfig.tsbuildinfo +1 -0
- package/template/client/vite.config.ts +12 -0
- package/template/cockpit.json +3 -0
- package/template/data/context-files/.gitkeep +0 -0
- package/template/data/questions/.gitkeep +0 -0
- package/template/data/topics.json +1 -0
- package/template/package.json +14 -0
- package/template/server/package-lock.json +2266 -0
- package/template/server/package.json +31 -0
- package/template/server/src/index.ts +758 -0
- package/template/server/src/storage.ts +303 -0
- package/template/server/tsconfig.json +14 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, Fragment } from "react";
|
|
2
|
+
import { useStore } from "../store";
|
|
3
|
+
import type { Question } from "../types";
|
|
4
|
+
import FileAttachments from "./FileAttachments";
|
|
5
|
+
import {
|
|
6
|
+
ChevronRight,
|
|
7
|
+
ChevronDown,
|
|
8
|
+
Plus,
|
|
9
|
+
Trash2,
|
|
10
|
+
MessageSquare,
|
|
11
|
+
X,
|
|
12
|
+
Pencil,
|
|
13
|
+
CornerDownRight,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
|
|
16
|
+
export default function Sidebar() {
|
|
17
|
+
const {
|
|
18
|
+
topics,
|
|
19
|
+
questionsByTopic,
|
|
20
|
+
expandedTopics,
|
|
21
|
+
selectedQuestionId,
|
|
22
|
+
toggleTopic,
|
|
23
|
+
addTopic,
|
|
24
|
+
removeTopic,
|
|
25
|
+
renameTopic,
|
|
26
|
+
addQuestion,
|
|
27
|
+
addChildQuestion,
|
|
28
|
+
removeQuestion,
|
|
29
|
+
renameQuestion,
|
|
30
|
+
selectQuestion,
|
|
31
|
+
fetchQuestions,
|
|
32
|
+
uploadTopicFiles,
|
|
33
|
+
removeTopicFile,
|
|
34
|
+
} = useStore();
|
|
35
|
+
|
|
36
|
+
const [newTopicName, setNewTopicName] = useState("");
|
|
37
|
+
const [showTopicInput, setShowTopicInput] = useState(false);
|
|
38
|
+
const [newQuestionTitle, setNewQuestionTitle] = useState("");
|
|
39
|
+
const [addingQuestionTo, setAddingQuestionTo] = useState<string | null>(null);
|
|
40
|
+
const [addingChildTo, setAddingChildTo] = useState<string | null>(null);
|
|
41
|
+
const [newChildTitle, setNewChildTitle] = useState("");
|
|
42
|
+
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
|
|
43
|
+
const [editingTopicName, setEditingTopicName] = useState("");
|
|
44
|
+
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(
|
|
45
|
+
null,
|
|
46
|
+
);
|
|
47
|
+
const [editingQuestionTitle, setEditingQuestionTitle] = useState("");
|
|
48
|
+
const editInputRef = useRef<HTMLInputElement>(null);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (editingTopicId || editingQuestionId) {
|
|
52
|
+
editInputRef.current?.select();
|
|
53
|
+
}
|
|
54
|
+
}, [editingTopicId, editingQuestionId]);
|
|
55
|
+
|
|
56
|
+
const commitTopicRename = async (id: string) => {
|
|
57
|
+
const trimmed = editingTopicName.trim();
|
|
58
|
+
if (trimmed) await renameTopic(id, trimmed);
|
|
59
|
+
setEditingTopicId(null);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const commitQuestionRename = async (questionId: string, topicId: string) => {
|
|
63
|
+
const trimmed = editingQuestionTitle.trim();
|
|
64
|
+
if (trimmed) await renameQuestion(questionId, topicId, trimmed);
|
|
65
|
+
setEditingQuestionId(null);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
topics.forEach((t) => {
|
|
70
|
+
if (!questionsByTopic[t.id]) fetchQuestions(t.id);
|
|
71
|
+
});
|
|
72
|
+
}, [topics]);
|
|
73
|
+
|
|
74
|
+
const handleAddTopic = async () => {
|
|
75
|
+
if (!newTopicName.trim()) return;
|
|
76
|
+
await addTopic(newTopicName.trim());
|
|
77
|
+
setNewTopicName("");
|
|
78
|
+
setShowTopicInput(false);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const handleAddQuestion = async (topicId: string) => {
|
|
82
|
+
if (!newQuestionTitle.trim()) return;
|
|
83
|
+
await addQuestion(topicId, newQuestionTitle.trim());
|
|
84
|
+
setNewQuestionTitle("");
|
|
85
|
+
setAddingQuestionTo(null);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleAddChildQuestion = async (
|
|
89
|
+
topicId: string,
|
|
90
|
+
parentQuestionId: string,
|
|
91
|
+
) => {
|
|
92
|
+
if (!newChildTitle.trim()) return;
|
|
93
|
+
await addChildQuestion(topicId, parentQuestionId, newChildTitle.trim());
|
|
94
|
+
setNewChildTitle("");
|
|
95
|
+
setAddingChildTo(null);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const renderQuestionRow = (
|
|
99
|
+
q: Question,
|
|
100
|
+
topicId: string,
|
|
101
|
+
isChild: boolean,
|
|
102
|
+
) => (
|
|
103
|
+
<div
|
|
104
|
+
key={q.id}
|
|
105
|
+
className={`group flex items-center gap-1.5 ${
|
|
106
|
+
isChild ? "pl-7" : "pl-3"
|
|
107
|
+
} pr-2 py-1 cursor-pointer transition-colors ${
|
|
108
|
+
selectedQuestionId === q.id
|
|
109
|
+
? "bg-cyan-500/10 border-l-2 border-l-cyan-500 -ml-px"
|
|
110
|
+
: "hover:bg-slate-800/30"
|
|
111
|
+
}`}
|
|
112
|
+
onClick={() =>
|
|
113
|
+
editingQuestionId !== q.id && selectQuestion(topicId, q.id)
|
|
114
|
+
}
|
|
115
|
+
>
|
|
116
|
+
{isChild && (
|
|
117
|
+
<span className="text-slate-600 text-[11px] shrink-0 leading-none select-none mr-0.5">
|
|
118
|
+
└
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
<MessageSquare className="w-3 h-3 text-slate-600 shrink-0" />
|
|
122
|
+
{editingQuestionId === q.id ? (
|
|
123
|
+
<input
|
|
124
|
+
ref={editInputRef}
|
|
125
|
+
value={editingQuestionTitle}
|
|
126
|
+
onChange={(e) => setEditingQuestionTitle(e.target.value)}
|
|
127
|
+
onKeyDown={(e) => {
|
|
128
|
+
if (e.key === "Enter") commitQuestionRename(q.id, topicId);
|
|
129
|
+
if (e.key === "Escape") setEditingQuestionId(null);
|
|
130
|
+
}}
|
|
131
|
+
onBlur={() => commitQuestionRename(q.id, topicId)}
|
|
132
|
+
onClick={(e) => e.stopPropagation()}
|
|
133
|
+
className="flex-1 min-w-0 bg-slate-700 border border-cyan-500 rounded px-1 py-0 text-xs text-slate-200 focus:outline-none"
|
|
134
|
+
/>
|
|
135
|
+
) : (
|
|
136
|
+
<span
|
|
137
|
+
className="text-xs text-slate-400 truncate flex-1"
|
|
138
|
+
onDoubleClick={(e) => {
|
|
139
|
+
e.stopPropagation();
|
|
140
|
+
setEditingQuestionId(q.id);
|
|
141
|
+
setEditingQuestionTitle(q.title);
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
{q.title}
|
|
145
|
+
</span>
|
|
146
|
+
)}
|
|
147
|
+
<span className="text-[10px] text-slate-700 shrink-0">
|
|
148
|
+
{q.messages.length > 0 ? `${q.messages.length}` : ""}
|
|
149
|
+
</span>
|
|
150
|
+
{editingQuestionId !== q.id && !isChild && (
|
|
151
|
+
<button
|
|
152
|
+
onClick={(e) => {
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
setAddingChildTo(q.id);
|
|
155
|
+
setNewChildTitle("");
|
|
156
|
+
}}
|
|
157
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
158
|
+
title="Add child question"
|
|
159
|
+
>
|
|
160
|
+
<CornerDownRight className="w-2.5 h-2.5" />
|
|
161
|
+
</button>
|
|
162
|
+
)}
|
|
163
|
+
{editingQuestionId !== q.id && (
|
|
164
|
+
<button
|
|
165
|
+
onClick={(e) => {
|
|
166
|
+
e.stopPropagation();
|
|
167
|
+
setEditingQuestionId(q.id);
|
|
168
|
+
setEditingQuestionTitle(q.title);
|
|
169
|
+
}}
|
|
170
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
171
|
+
title="Rename"
|
|
172
|
+
>
|
|
173
|
+
<Pencil className="w-2.5 h-2.5" />
|
|
174
|
+
</button>
|
|
175
|
+
)}
|
|
176
|
+
<button
|
|
177
|
+
onClick={(e) => {
|
|
178
|
+
e.stopPropagation();
|
|
179
|
+
removeQuestion(q.id, topicId);
|
|
180
|
+
}}
|
|
181
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
|
|
182
|
+
>
|
|
183
|
+
<Trash2 className="w-2.5 h-2.5" />
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<aside className="w-72 border-r border-slate-800 flex flex-col bg-slate-900/50 shrink-0">
|
|
190
|
+
{/* Header */}
|
|
191
|
+
<div className="h-12 border-b border-slate-800 flex items-center justify-between px-3">
|
|
192
|
+
<span className="text-xs font-bold uppercase tracking-wider text-slate-500">
|
|
193
|
+
Topics
|
|
194
|
+
</span>
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => setShowTopicInput(true)}
|
|
197
|
+
className="p-1 rounded hover:bg-slate-800 text-slate-500 hover:text-slate-300 transition-colors"
|
|
198
|
+
title="Add topic"
|
|
199
|
+
>
|
|
200
|
+
<Plus className="w-4 h-4" />
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* Add topic input */}
|
|
205
|
+
{showTopicInput && (
|
|
206
|
+
<div className="p-2 border-b border-slate-800 animate-fadeIn">
|
|
207
|
+
<div className="flex gap-1">
|
|
208
|
+
<input
|
|
209
|
+
autoFocus
|
|
210
|
+
value={newTopicName}
|
|
211
|
+
onChange={(e) => setNewTopicName(e.target.value)}
|
|
212
|
+
onKeyDown={(e) => {
|
|
213
|
+
if (e.key === "Enter") handleAddTopic();
|
|
214
|
+
if (e.key === "Escape") setShowTopicInput(false);
|
|
215
|
+
}}
|
|
216
|
+
placeholder="Topic name..."
|
|
217
|
+
className="flex-1 bg-slate-800 border border-slate-700 rounded px-2 py-1 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-cyan-500"
|
|
218
|
+
/>
|
|
219
|
+
<button
|
|
220
|
+
onClick={handleAddTopic}
|
|
221
|
+
className="px-2 py-1 bg-cyan-600 hover:bg-cyan-500 text-white text-xs rounded transition-colors"
|
|
222
|
+
>
|
|
223
|
+
Add
|
|
224
|
+
</button>
|
|
225
|
+
<button
|
|
226
|
+
onClick={() => setShowTopicInput(false)}
|
|
227
|
+
className="p-1 text-slate-500 hover:text-slate-300"
|
|
228
|
+
>
|
|
229
|
+
<X className="w-3 h-3" />
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Topic list */}
|
|
236
|
+
<div className="flex-1 overflow-y-auto">
|
|
237
|
+
{topics.length === 0 && (
|
|
238
|
+
<div className="p-4 text-center">
|
|
239
|
+
<p className="text-sm text-slate-600">No topics yet</p>
|
|
240
|
+
<p className="text-xs text-slate-700 mt-1">Click + to add one</p>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{topics.map((topic) => {
|
|
245
|
+
const isExpanded = expandedTopics.includes(topic.id);
|
|
246
|
+
const questions = questionsByTopic[topic.id] || [];
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div key={topic.id}>
|
|
250
|
+
{/* Topic header */}
|
|
251
|
+
<div className="group flex items-center gap-1 px-2 py-1.5 hover:bg-slate-800/50 cursor-pointer">
|
|
252
|
+
<button
|
|
253
|
+
onClick={() => toggleTopic(topic.id)}
|
|
254
|
+
className="flex items-center gap-1 flex-1 min-w-0"
|
|
255
|
+
>
|
|
256
|
+
{isExpanded ? (
|
|
257
|
+
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
258
|
+
) : (
|
|
259
|
+
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
260
|
+
)}
|
|
261
|
+
{editingTopicId === topic.id ? (
|
|
262
|
+
<input
|
|
263
|
+
ref={editInputRef}
|
|
264
|
+
value={editingTopicName}
|
|
265
|
+
onChange={(e) => setEditingTopicName(e.target.value)}
|
|
266
|
+
onKeyDown={(e) => {
|
|
267
|
+
if (e.key === "Enter") commitTopicRename(topic.id);
|
|
268
|
+
if (e.key === "Escape") setEditingTopicId(null);
|
|
269
|
+
}}
|
|
270
|
+
onBlur={() => commitTopicRename(topic.id)}
|
|
271
|
+
onClick={(e) => e.stopPropagation()}
|
|
272
|
+
className="flex-1 min-w-0 bg-slate-700 border border-cyan-500 rounded px-1 py-0 text-sm text-slate-200 focus:outline-none"
|
|
273
|
+
/>
|
|
274
|
+
) : (
|
|
275
|
+
<span
|
|
276
|
+
className="text-sm font-medium text-slate-300 truncate"
|
|
277
|
+
onDoubleClick={(e) => {
|
|
278
|
+
e.stopPropagation();
|
|
279
|
+
setEditingTopicId(topic.id);
|
|
280
|
+
setEditingTopicName(topic.name);
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
{topic.name}
|
|
284
|
+
</span>
|
|
285
|
+
)}
|
|
286
|
+
<span className="text-xs text-slate-600 ml-auto shrink-0">
|
|
287
|
+
{questions.length}
|
|
288
|
+
</span>
|
|
289
|
+
</button>
|
|
290
|
+
{editingTopicId !== topic.id && (
|
|
291
|
+
<button
|
|
292
|
+
onClick={(e) => {
|
|
293
|
+
e.stopPropagation();
|
|
294
|
+
setEditingTopicId(topic.id);
|
|
295
|
+
setEditingTopicName(topic.name);
|
|
296
|
+
}}
|
|
297
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
|
|
298
|
+
title="Rename"
|
|
299
|
+
>
|
|
300
|
+
<Pencil className="w-3 h-3" />
|
|
301
|
+
</button>
|
|
302
|
+
)}
|
|
303
|
+
<button
|
|
304
|
+
onClick={(e) => {
|
|
305
|
+
e.stopPropagation();
|
|
306
|
+
if (
|
|
307
|
+
confirm(
|
|
308
|
+
`Delete topic "${topic.name}" and all its questions?`,
|
|
309
|
+
)
|
|
310
|
+
) {
|
|
311
|
+
removeTopic(topic.id);
|
|
312
|
+
}
|
|
313
|
+
}}
|
|
314
|
+
className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
|
|
315
|
+
>
|
|
316
|
+
<Trash2 className="w-3 h-3" />
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
{/* Questions list */}
|
|
321
|
+
{isExpanded && (
|
|
322
|
+
<div className="ml-3 border-l border-slate-800">
|
|
323
|
+
{/* Topic-level file attachments */}
|
|
324
|
+
<div className="pl-3 pr-2 py-1.5 border-b border-slate-800/50">
|
|
325
|
+
<FileAttachments
|
|
326
|
+
files={topic.contextFiles || []}
|
|
327
|
+
onUpload={(files) => uploadTopicFiles(topic.id, files)}
|
|
328
|
+
onRemove={(fileId) => removeTopicFile(topic.id, fileId)}
|
|
329
|
+
label="topic"
|
|
330
|
+
compact
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
{/* Questions grouped: root → children */}
|
|
335
|
+
{(() => {
|
|
336
|
+
const rootQs = questions.filter((q) => !q.parentQuestionId);
|
|
337
|
+
return rootQs.map((q) => {
|
|
338
|
+
const children = questions.filter(
|
|
339
|
+
(c) => c.parentQuestionId === q.id,
|
|
340
|
+
);
|
|
341
|
+
return (
|
|
342
|
+
<Fragment key={q.id}>
|
|
343
|
+
{renderQuestionRow(q, topic.id, false)}
|
|
344
|
+
{/* Add-child input */}
|
|
345
|
+
{addingChildTo === q.id && (
|
|
346
|
+
<div className="pl-7 pr-2 py-1 animate-fadeIn">
|
|
347
|
+
<input
|
|
348
|
+
autoFocus
|
|
349
|
+
value={newChildTitle}
|
|
350
|
+
onChange={(e) =>
|
|
351
|
+
setNewChildTitle(e.target.value)
|
|
352
|
+
}
|
|
353
|
+
onKeyDown={(e) => {
|
|
354
|
+
if (e.key === "Enter")
|
|
355
|
+
handleAddChildQuestion(topic.id, q.id);
|
|
356
|
+
if (e.key === "Escape") {
|
|
357
|
+
setAddingChildTo(null);
|
|
358
|
+
setNewChildTitle("");
|
|
359
|
+
}
|
|
360
|
+
}}
|
|
361
|
+
onBlur={() => {
|
|
362
|
+
if (!newChildTitle.trim())
|
|
363
|
+
setAddingChildTo(null);
|
|
364
|
+
}}
|
|
365
|
+
placeholder="Child question title..."
|
|
366
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
{/* Children */}
|
|
371
|
+
{children.map((c) =>
|
|
372
|
+
renderQuestionRow(c, topic.id, true),
|
|
373
|
+
)}
|
|
374
|
+
</Fragment>
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
})()}
|
|
378
|
+
|
|
379
|
+
{/* Add question input */}
|
|
380
|
+
{addingQuestionTo === topic.id ? (
|
|
381
|
+
<div className="pl-3 pr-2 py-1 animate-fadeIn">
|
|
382
|
+
<input
|
|
383
|
+
autoFocus
|
|
384
|
+
value={newQuestionTitle}
|
|
385
|
+
onChange={(e) => setNewQuestionTitle(e.target.value)}
|
|
386
|
+
onKeyDown={(e) => {
|
|
387
|
+
if (e.key === "Enter") handleAddQuestion(topic.id);
|
|
388
|
+
if (e.key === "Escape") {
|
|
389
|
+
setAddingQuestionTo(null);
|
|
390
|
+
setNewQuestionTitle("");
|
|
391
|
+
}
|
|
392
|
+
}}
|
|
393
|
+
onBlur={() => {
|
|
394
|
+
if (!newQuestionTitle.trim()) {
|
|
395
|
+
setAddingQuestionTo(null);
|
|
396
|
+
}
|
|
397
|
+
}}
|
|
398
|
+
placeholder="Question title..."
|
|
399
|
+
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
|
|
400
|
+
/>
|
|
401
|
+
</div>
|
|
402
|
+
) : (
|
|
403
|
+
<button
|
|
404
|
+
onClick={() => setAddingQuestionTo(topic.id)}
|
|
405
|
+
className="flex items-center gap-1 pl-3 pr-2 py-1 text-xs text-slate-600 hover:text-cyan-400 transition-colors w-full"
|
|
406
|
+
>
|
|
407
|
+
<Plus className="w-3 h-3" />
|
|
408
|
+
Add question
|
|
409
|
+
</button>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
})}
|
|
416
|
+
</div>
|
|
417
|
+
</aside>
|
|
418
|
+
);
|
|
419
|
+
}
|