claudeck 1.2.0 → 1.3.1
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 +64 -5
- package/cli.js +53 -4
- package/package.json +3 -2
- package/public/css/core/responsive.css +2 -2
- package/public/css/ui/file-picker.css +243 -17
- package/public/css/ui/messages.css +72 -9
- package/public/css/ui/toolbox.css +43 -0
- package/public/index.html +80 -745
- package/public/js/components/add-project-modal.js +27 -0
- package/public/js/components/agent-modal.js +73 -0
- package/public/js/components/agent-monitor-modal.js +19 -0
- package/public/js/components/bg-confirm-modal.js +22 -0
- package/public/js/components/chain-modal.js +52 -0
- package/public/js/components/cost-dashboard-modal.js +39 -0
- package/public/js/components/dag-editor-modal.js +55 -0
- package/public/js/components/file-picker-modal.js +45 -0
- package/public/js/components/linear-create-modal.js +43 -0
- package/public/js/components/mcp-modal.js +58 -0
- package/public/js/components/orchestrate-modal.js +40 -0
- package/public/js/components/permission-modal.js +30 -0
- package/public/js/components/prompt-modal.js +31 -0
- package/public/js/components/shortcuts-modal.js +45 -0
- package/public/js/components/status-bar.js +97 -0
- package/public/js/components/system-prompt-modal.js +29 -0
- package/public/js/components/telegram-modal.js +84 -0
- package/public/js/components/welcome-overlay.js +60 -0
- package/public/js/components/workflow-modal.js +41 -0
- package/public/js/core/api.js +10 -0
- package/public/js/core/dom.js +3 -2
- package/public/js/core/ws.js +7 -1
- package/public/js/features/attachments.js +226 -23
- package/public/js/features/projects.js +7 -0
- package/public/js/main.js +22 -0
- package/public/js/ui/shortcuts.js +4 -8
- package/public/login.html +470 -0
- package/public/offline.html +300 -168
- package/public/sw.js +10 -2
- package/server/agent-loop.js +1 -0
- package/server/auth.js +141 -0
- package/server/orchestrator.js +1 -0
- package/server/ws-handler.js +2 -0
- package/server.js +14 -3
|
@@ -7,6 +7,45 @@ import { registerCommand } from '../ui/commands.js';
|
|
|
7
7
|
const SUPPORTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
8
8
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
9
9
|
|
|
10
|
+
// ── File type categorization ─────────────────────────────
|
|
11
|
+
const FILE_TYPES = {
|
|
12
|
+
code: new Set(['js','jsx','ts','tsx','mjs','cjs','py','go','rs','java','rb','php','c','cpp','h','hpp','swift','kt','scala','sh','bash','zsh','lua','r','pl','ex','exs','elm','hs','clj','dart','cs','vue','svelte']),
|
|
13
|
+
config: new Set(['json','yaml','yml','toml','env','ini','lock','conf','cfg','properties','editorconfig','gitignore','dockerignore','eslintrc','prettierrc','babelrc','nvmrc']),
|
|
14
|
+
markup: new Set(['html','htm','css','scss','sass','less','xml','svg','astro','njk','ejs','hbs','pug','styl']),
|
|
15
|
+
docs: new Set(['md','txt','rst','adoc','tex','org','log']),
|
|
16
|
+
data: new Set(['csv','sql','graphql','gql','prisma','tsv']),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const BINARY_EXTENSIONS = new Set([
|
|
20
|
+
'png','jpg','jpeg','gif','webp','bmp','ico','tiff','tif',
|
|
21
|
+
'pdf','zip','gz','tar','rar','7z','bz2','xz',
|
|
22
|
+
'exe','dll','so','dylib','o','a','wasm','bin','dat',
|
|
23
|
+
'db','sqlite','sqlite3',
|
|
24
|
+
'mp3','mp4','avi','mov','flv','wmv','wav','ogg','flac','aac','m4a',
|
|
25
|
+
'ttf','otf','woff','woff2','eot',
|
|
26
|
+
'class','jar','pyc','pyo',
|
|
27
|
+
'DS_Store',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function getFileExt(filePath) {
|
|
31
|
+
const dot = filePath.lastIndexOf('.');
|
|
32
|
+
if (dot < 0) return '';
|
|
33
|
+
return filePath.slice(dot + 1).toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getFileCategory(filePath) {
|
|
37
|
+
const ext = getFileExt(filePath);
|
|
38
|
+
if (BINARY_EXTENSIONS.has(ext)) return 'binary';
|
|
39
|
+
for (const [cat, exts] of Object.entries(FILE_TYPES)) {
|
|
40
|
+
if (exts.has(ext)) return cat;
|
|
41
|
+
}
|
|
42
|
+
return 'default';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isBinaryFile(filePath) {
|
|
46
|
+
return BINARY_EXTENSIONS.has(getFileExt(filePath));
|
|
47
|
+
}
|
|
48
|
+
|
|
10
49
|
// ── Badge ────────────────────────────────────────────────
|
|
11
50
|
export function updateAttachmentBadge() {
|
|
12
51
|
const attachedFiles = getState("attachedFiles");
|
|
@@ -20,7 +59,59 @@ export function updateAttachmentBadge() {
|
|
|
20
59
|
}
|
|
21
60
|
}
|
|
22
61
|
|
|
23
|
-
// ──
|
|
62
|
+
// ── Selected chips ───────────────────────────────────────
|
|
63
|
+
function renderSelectedChips() {
|
|
64
|
+
const attachedFiles = getState("attachedFiles");
|
|
65
|
+
$.fpSelected.innerHTML = "";
|
|
66
|
+
|
|
67
|
+
if (attachedFiles.length === 0) {
|
|
68
|
+
$.fpSelected.classList.add("hidden");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
$.fpSelected.classList.remove("hidden");
|
|
73
|
+
for (const file of attachedFiles) {
|
|
74
|
+
const chip = document.createElement("div");
|
|
75
|
+
chip.className = "fp-chip";
|
|
76
|
+
|
|
77
|
+
const name = document.createElement("span");
|
|
78
|
+
name.className = "fp-chip-name";
|
|
79
|
+
name.textContent = file.path;
|
|
80
|
+
name.title = file.path;
|
|
81
|
+
|
|
82
|
+
const removeBtn = document.createElement("button");
|
|
83
|
+
removeBtn.className = "fp-chip-remove";
|
|
84
|
+
removeBtn.textContent = "\u00d7";
|
|
85
|
+
removeBtn.title = "Remove";
|
|
86
|
+
removeBtn.addEventListener("click", (e) => {
|
|
87
|
+
e.stopPropagation();
|
|
88
|
+
removeFileByPath(file.path);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
chip.appendChild(name);
|
|
92
|
+
chip.appendChild(removeBtn);
|
|
93
|
+
$.fpSelected.appendChild(chip);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function removeFileByPath(path) {
|
|
98
|
+
const attachedFiles = getState("attachedFiles").filter(f => f.path !== path);
|
|
99
|
+
setState("attachedFiles", attachedFiles);
|
|
100
|
+
renderSelectedChips();
|
|
101
|
+
updateFooterCount();
|
|
102
|
+
updateAttachmentBadge();
|
|
103
|
+
// Update item in list if visible
|
|
104
|
+
const items = $.fpList.querySelectorAll(".file-picker-item");
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
if (item.dataset.path === path) {
|
|
107
|
+
item.classList.remove("selected");
|
|
108
|
+
const check = item.querySelector(".fp-check");
|
|
109
|
+
if (check) check.style.opacity = "0";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── File picker ──────────────────────────────────────────
|
|
24
115
|
export async function openFilePicker() {
|
|
25
116
|
const cwd = $.projectSelect.value;
|
|
26
117
|
if (!cwd) return;
|
|
@@ -48,38 +139,144 @@ export function renderFilePicker(filter) {
|
|
|
48
139
|
? allProjectFiles.filter((f) => f.toLowerCase().includes(lower))
|
|
49
140
|
: allProjectFiles;
|
|
50
141
|
|
|
51
|
-
|
|
142
|
+
const visible = filtered.slice(0, 200);
|
|
143
|
+
|
|
144
|
+
// Show/hide empty state
|
|
145
|
+
if (visible.length === 0) {
|
|
146
|
+
$.fpEmpty.classList.remove("hidden");
|
|
147
|
+
$.fpList.style.display = "none";
|
|
148
|
+
} else {
|
|
149
|
+
$.fpEmpty.classList.add("hidden");
|
|
150
|
+
$.fpList.style.display = "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const filePath of visible) {
|
|
154
|
+
const category = getFileCategory(filePath);
|
|
155
|
+
const binary = category === 'binary';
|
|
156
|
+
const isSelected = attachedFiles.some((f) => f.path === filePath);
|
|
157
|
+
|
|
52
158
|
const item = document.createElement("div");
|
|
53
159
|
item.className = "file-picker-item";
|
|
54
|
-
|
|
160
|
+
item.dataset.path = filePath;
|
|
55
161
|
if (isSelected) item.classList.add("selected");
|
|
56
|
-
item.
|
|
57
|
-
|
|
162
|
+
if (binary) item.classList.add("binary-warn");
|
|
163
|
+
|
|
164
|
+
// Type dot
|
|
165
|
+
const dot = document.createElement("span");
|
|
166
|
+
dot.className = `fp-type-dot type-${category}`;
|
|
167
|
+
item.appendChild(dot);
|
|
168
|
+
|
|
169
|
+
// File path
|
|
170
|
+
const pathEl = document.createElement("span");
|
|
171
|
+
pathEl.className = "fp-path";
|
|
172
|
+
pathEl.textContent = filePath;
|
|
173
|
+
item.appendChild(pathEl);
|
|
174
|
+
|
|
175
|
+
if (binary) {
|
|
176
|
+
// Binary label
|
|
177
|
+
const label = document.createElement("span");
|
|
178
|
+
label.className = "fp-binary-label";
|
|
179
|
+
label.textContent = "binary";
|
|
180
|
+
item.appendChild(label);
|
|
181
|
+
} else {
|
|
182
|
+
// Checkmark
|
|
183
|
+
const check = document.createElement("span");
|
|
184
|
+
check.className = "fp-check";
|
|
185
|
+
check.textContent = "\u2713";
|
|
186
|
+
item.appendChild(check);
|
|
187
|
+
|
|
188
|
+
item.addEventListener("click", () => toggleFileAttachment(filePath, item));
|
|
189
|
+
}
|
|
190
|
+
|
|
58
191
|
$.fpList.appendChild(item);
|
|
59
192
|
}
|
|
193
|
+
|
|
194
|
+
renderSelectedChips();
|
|
195
|
+
updateFooterCount();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function updateFooterCount() {
|
|
199
|
+
const attachedFiles = getState("attachedFiles");
|
|
200
|
+
const count = attachedFiles.length;
|
|
201
|
+
$.fpCount.textContent = `${count} file${count !== 1 ? "s" : ""} selected`;
|
|
60
202
|
}
|
|
61
203
|
|
|
62
204
|
async function toggleFileAttachment(filePath, itemEl) {
|
|
63
205
|
const attachedFiles = [...getState("attachedFiles")];
|
|
64
206
|
const idx = attachedFiles.findIndex((f) => f.path === filePath);
|
|
207
|
+
|
|
65
208
|
if (idx >= 0) {
|
|
209
|
+
// Deselect
|
|
66
210
|
attachedFiles.splice(idx, 1);
|
|
67
211
|
setState("attachedFiles", attachedFiles);
|
|
68
212
|
itemEl.classList.remove("selected");
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
attachedFiles.push({ path: filePath, content: data.content });
|
|
74
|
-
setState("attachedFiles", attachedFiles);
|
|
75
|
-
itemEl.classList.add("selected");
|
|
76
|
-
} catch (err) {
|
|
77
|
-
console.error("Failed to read file:", err);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
213
|
+
renderSelectedChips();
|
|
214
|
+
updateFooterCount();
|
|
215
|
+
updateAttachmentBadge();
|
|
216
|
+
return;
|
|
80
217
|
}
|
|
81
|
-
|
|
82
|
-
|
|
218
|
+
|
|
219
|
+
// Show loading state
|
|
220
|
+
itemEl.classList.add("loading");
|
|
221
|
+
const check = itemEl.querySelector(".fp-check");
|
|
222
|
+
if (check) check.style.display = "none";
|
|
223
|
+
const spinner = document.createElement("span");
|
|
224
|
+
spinner.className = "fp-spinner";
|
|
225
|
+
itemEl.appendChild(spinner);
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const cwd = $.projectSelect.value;
|
|
229
|
+
const data = await api.fetchFileContent(cwd, filePath);
|
|
230
|
+
// Remove spinner, show selected
|
|
231
|
+
spinner.remove();
|
|
232
|
+
if (check) check.style.display = "";
|
|
233
|
+
itemEl.classList.remove("loading");
|
|
234
|
+
itemEl.classList.add("selected");
|
|
235
|
+
|
|
236
|
+
const updated = [...getState("attachedFiles")];
|
|
237
|
+
updated.push({ path: filePath, content: data.content });
|
|
238
|
+
setState("attachedFiles", updated);
|
|
239
|
+
renderSelectedChips();
|
|
240
|
+
updateFooterCount();
|
|
241
|
+
updateAttachmentBadge();
|
|
242
|
+
} catch (err) {
|
|
243
|
+
// Remove spinner, show error
|
|
244
|
+
spinner.remove();
|
|
245
|
+
if (check) check.style.display = "";
|
|
246
|
+
itemEl.classList.remove("loading");
|
|
247
|
+
showItemError(itemEl, parseFileError(err.message));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseFileError(message) {
|
|
252
|
+
if (message.includes("50KB") || message.includes("too large") || message.includes("413")) {
|
|
253
|
+
return "Too large (50KB limit)";
|
|
254
|
+
}
|
|
255
|
+
if (message.includes("ENOENT") || message.includes("not found")) {
|
|
256
|
+
return "File not found";
|
|
257
|
+
}
|
|
258
|
+
if (message.includes("EACCES") || message.includes("permission")) {
|
|
259
|
+
return "Permission denied";
|
|
260
|
+
}
|
|
261
|
+
return "Cannot read file";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function showItemError(itemEl, message) {
|
|
265
|
+
itemEl.classList.add("error");
|
|
266
|
+
// Remove existing error msg if any
|
|
267
|
+
const existing = itemEl.querySelector(".fp-error-msg");
|
|
268
|
+
if (existing) existing.remove();
|
|
269
|
+
|
|
270
|
+
const errorMsg = document.createElement("span");
|
|
271
|
+
errorMsg.className = "fp-error-msg";
|
|
272
|
+
errorMsg.textContent = message;
|
|
273
|
+
itemEl.appendChild(errorMsg);
|
|
274
|
+
|
|
275
|
+
// Auto-clear after 3s
|
|
276
|
+
setTimeout(() => {
|
|
277
|
+
itemEl.classList.remove("error");
|
|
278
|
+
errorMsg.remove();
|
|
279
|
+
}, 3000);
|
|
83
280
|
}
|
|
84
281
|
|
|
85
282
|
function closeFilePicker() {
|
|
@@ -89,11 +286,11 @@ function closeFilePicker() {
|
|
|
89
286
|
// ── Image attachments ────────────────────────────────────
|
|
90
287
|
export function addImageAttachment(file) {
|
|
91
288
|
if (!SUPPORTED_IMAGE_TYPES.includes(file.type)) {
|
|
92
|
-
showImageError(`Unsupported
|
|
289
|
+
showImageError(`Unsupported type: ${file.type || "unknown"}. Use PNG, JPEG, GIF, or WebP.`);
|
|
93
290
|
return;
|
|
94
291
|
}
|
|
95
292
|
if (file.size > MAX_IMAGE_SIZE) {
|
|
96
|
-
showImageError(`Image too large (${(file.size / 1024 / 1024).toFixed(1)}MB). Max
|
|
293
|
+
showImageError(`Image too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Max 5 MB.`);
|
|
97
294
|
return;
|
|
98
295
|
}
|
|
99
296
|
|
|
@@ -145,7 +342,7 @@ function renderImagePreview() {
|
|
|
145
342
|
const imgEl = document.createElement("img");
|
|
146
343
|
imgEl.src = `data:${img.mimeType};base64,${img.data}`;
|
|
147
344
|
imgEl.alt = img.name;
|
|
148
|
-
imgEl.title = img.name
|
|
345
|
+
imgEl.title = `${img.name}`;
|
|
149
346
|
|
|
150
347
|
const removeBtn = document.createElement("button");
|
|
151
348
|
removeBtn.className = "image-preview-remove";
|
|
@@ -162,7 +359,6 @@ function renderImagePreview() {
|
|
|
162
359
|
}
|
|
163
360
|
|
|
164
361
|
function showImageError(message) {
|
|
165
|
-
// Use toast container if available
|
|
166
362
|
const container = document.getElementById("toast-container");
|
|
167
363
|
if (container) {
|
|
168
364
|
const toast = document.createElement("div");
|
|
@@ -203,7 +399,6 @@ $.imageFileInput.addEventListener("change", () => {
|
|
|
203
399
|
|
|
204
400
|
// Paste handler — detect images in clipboard
|
|
205
401
|
document.addEventListener("paste", (e) => {
|
|
206
|
-
// Only handle when focus is in the chat input area
|
|
207
402
|
const active = document.activeElement;
|
|
208
403
|
if (active !== $.messageInput && !$.messageInput.contains(active)) return;
|
|
209
404
|
|
|
@@ -234,11 +429,19 @@ $.messageInput.addEventListener("drop", (e) => {
|
|
|
234
429
|
$.messageInput.classList.remove("drag-highlight");
|
|
235
430
|
if (!e.dataTransfer.files.length) return;
|
|
236
431
|
e.preventDefault();
|
|
432
|
+
|
|
433
|
+
let hasUnsupported = false;
|
|
237
434
|
for (const file of e.dataTransfer.files) {
|
|
238
435
|
if (SUPPORTED_IMAGE_TYPES.includes(file.type)) {
|
|
239
436
|
addImageAttachment(file);
|
|
437
|
+
} else {
|
|
438
|
+
hasUnsupported = true;
|
|
240
439
|
}
|
|
241
440
|
}
|
|
441
|
+
|
|
442
|
+
if (hasUnsupported) {
|
|
443
|
+
showImageError("Only images (PNG, JPEG, GIF, WebP) can be dropped here. Use the attach button for code files.");
|
|
444
|
+
}
|
|
242
445
|
});
|
|
243
446
|
|
|
244
447
|
// ── Commands ─────────────────────────────────────────────
|
|
@@ -8,6 +8,7 @@ import { panes } from '../ui/parallel.js';
|
|
|
8
8
|
import { loadSessions } from './sessions.js';
|
|
9
9
|
import { loadStats } from './cost-dashboard.js';
|
|
10
10
|
import { showWhalyPlaceholder, addSkillUsedMessage } from '../ui/messages.js';
|
|
11
|
+
import { updateAttachmentBadge, clearImageAttachments } from './attachments.js';
|
|
11
12
|
|
|
12
13
|
export async function loadProjects() {
|
|
13
14
|
try {
|
|
@@ -362,6 +363,12 @@ $.projectSelect.addEventListener("change", async () => {
|
|
|
362
363
|
if ($.projectSelect.value) {
|
|
363
364
|
setState("view", "chat");
|
|
364
365
|
}
|
|
366
|
+
// Clear attachments and input on project switch
|
|
367
|
+
setState("attachedFiles", []);
|
|
368
|
+
setState("allProjectFiles", []);
|
|
369
|
+
clearImageAttachments();
|
|
370
|
+
updateAttachmentBadge();
|
|
371
|
+
$.messageInput.value = "";
|
|
365
372
|
updateSystemPromptIndicator();
|
|
366
373
|
updateHeaderProjectName();
|
|
367
374
|
updateSessionControls();
|
package/public/js/main.js
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
// Entry point — imports all modules and runs boot sequence
|
|
2
|
+
|
|
3
|
+
// Web Components — must load before dom.js so getElementById finds their elements
|
|
4
|
+
import './components/welcome-overlay.js';
|
|
5
|
+
import './components/orchestrate-modal.js';
|
|
6
|
+
import './components/agent-monitor-modal.js';
|
|
7
|
+
import './components/dag-editor-modal.js';
|
|
8
|
+
import './components/chain-modal.js';
|
|
9
|
+
import './components/agent-modal.js';
|
|
10
|
+
import './components/workflow-modal.js';
|
|
11
|
+
import './components/prompt-modal.js';
|
|
12
|
+
import './components/system-prompt-modal.js';
|
|
13
|
+
import './components/file-picker-modal.js';
|
|
14
|
+
import './components/shortcuts-modal.js';
|
|
15
|
+
import './components/cost-dashboard-modal.js';
|
|
16
|
+
import './components/bg-confirm-modal.js';
|
|
17
|
+
import './components/permission-modal.js';
|
|
18
|
+
import './components/linear-create-modal.js';
|
|
19
|
+
import './components/telegram-modal.js';
|
|
20
|
+
import './components/mcp-modal.js';
|
|
21
|
+
import './components/add-project-modal.js';
|
|
22
|
+
import './components/status-bar.js';
|
|
23
|
+
|
|
2
24
|
import './core/store.js';
|
|
3
25
|
import './core/dom.js';
|
|
4
26
|
import './core/constants.js';
|
|
@@ -11,12 +11,8 @@ function closeAllModals() {
|
|
|
11
11
|
document.querySelectorAll(".modal-overlay:not([data-persistent])").forEach((m) => m.classList.add("hidden"));
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
});
|
|
17
|
-
$.shortcutsModal.addEventListener("click", (e) => {
|
|
18
|
-
if (e.target === $.shortcutsModal) $.shortcutsModal.classList.add("hidden");
|
|
19
|
-
});
|
|
14
|
+
// Shortcuts modal ref — rendered by <claudeck-shortcuts-modal> web component
|
|
15
|
+
const shortcutsModal = document.getElementById("shortcuts-modal");
|
|
20
16
|
|
|
21
17
|
document.addEventListener("keydown", (e) => {
|
|
22
18
|
const isMeta = e.metaKey || e.ctrlKey;
|
|
@@ -43,7 +39,7 @@ document.addEventListener("keydown", (e) => {
|
|
|
43
39
|
|
|
44
40
|
if (isMeta && e.key === "/") {
|
|
45
41
|
e.preventDefault();
|
|
46
|
-
|
|
42
|
+
shortcutsModal.classList.toggle("hidden");
|
|
47
43
|
return;
|
|
48
44
|
}
|
|
49
45
|
|
|
@@ -113,7 +109,7 @@ registerCommand("shortcuts", {
|
|
|
113
109
|
category: "app",
|
|
114
110
|
description: "Show keyboard shortcuts",
|
|
115
111
|
execute() {
|
|
116
|
-
|
|
112
|
+
shortcutsModal.classList.remove("hidden");
|
|
117
113
|
},
|
|
118
114
|
});
|
|
119
115
|
|