mcp-config-manager 1.0.13 → 2.1.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 +81 -5
- package/package.json +1 -1
- package/public/index.html +104 -19
- package/public/js/clientView.js +53 -19
- package/public/js/iconPreferences.js +98 -0
- package/public/js/kanbanView.js +25 -42
- package/public/js/logoService.js +392 -0
- package/public/js/main.js +21 -1
- package/public/js/modals.js +344 -24
- package/public/js/popularMcps.js +304 -0
- package/public/js/serverView.js +24 -7
- package/public/mcp-logos.json +26 -0
- package/public/popular-mcps.json +555 -0
- package/public/style.css +617 -1
- package/src/config-manager.js +3 -5
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logo Service - Automatic icon detection for MCP servers
|
|
3
|
+
* Tries multiple strategies to find an appropriate logo
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getIconPreference } from './iconPreferences.js';
|
|
7
|
+
|
|
8
|
+
let staticLogos = null;
|
|
9
|
+
const logoCache = new Map();
|
|
10
|
+
|
|
11
|
+
// Logo.dev API configuration (free tier)
|
|
12
|
+
const LOGO_DEV_API_KEY = 'pk_Rd3I7mQnQ3a-3VJxZemUPA';
|
|
13
|
+
const LOGO_DEV_BASE_URL = 'https://img.logo.dev';
|
|
14
|
+
|
|
15
|
+
// Load static logos from mcp-logos.json
|
|
16
|
+
export async function loadStaticLogos() {
|
|
17
|
+
if (staticLogos) return staticLogos;
|
|
18
|
+
try {
|
|
19
|
+
const response = await fetch('/mcp-logos.json');
|
|
20
|
+
staticLogos = await response.json();
|
|
21
|
+
return staticLogos;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error('Failed to load static logos:', error);
|
|
24
|
+
staticLogos = {};
|
|
25
|
+
return staticLogos;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get logo URL for a server
|
|
31
|
+
* @param {string} serverName - The server name/id
|
|
32
|
+
* @param {object} serverConfig - Optional server config with command/args
|
|
33
|
+
* @param {boolean} skipCustomPrefs - Skip custom preferences check (used internally)
|
|
34
|
+
* @returns {Promise<string|null>} Logo URL or null
|
|
35
|
+
*/
|
|
36
|
+
export async function getServerLogo(serverName, serverConfig = null, skipCustomPrefs = false) {
|
|
37
|
+
// Check cache first
|
|
38
|
+
const cacheKey = serverName.toLowerCase();
|
|
39
|
+
if (logoCache.has(cacheKey)) {
|
|
40
|
+
return logoCache.get(cacheKey);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Strategy 0: Check custom icon preferences first (unless skipped)
|
|
44
|
+
if (!skipCustomPrefs) {
|
|
45
|
+
const customPref = getIconPreference(serverName);
|
|
46
|
+
if (customPref) {
|
|
47
|
+
let logoUrl = null;
|
|
48
|
+
switch (customPref.type) {
|
|
49
|
+
case 'none':
|
|
50
|
+
// User wants no icon, return null
|
|
51
|
+
logoCache.set(cacheKey, null);
|
|
52
|
+
return null;
|
|
53
|
+
case 'url':
|
|
54
|
+
// User provided a custom URL
|
|
55
|
+
logoUrl = customPref.value;
|
|
56
|
+
logoCache.set(cacheKey, logoUrl);
|
|
57
|
+
return logoUrl;
|
|
58
|
+
case 'logodev':
|
|
59
|
+
// User selected a specific domain from logo.dev
|
|
60
|
+
logoUrl = getLogoDevUrl(customPref.value);
|
|
61
|
+
logoCache.set(cacheKey, logoUrl);
|
|
62
|
+
return logoUrl;
|
|
63
|
+
// 'auto' falls through to normal detection
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Ensure static logos are loaded
|
|
69
|
+
await loadStaticLogos();
|
|
70
|
+
|
|
71
|
+
// Try various strategies
|
|
72
|
+
let logoUrl = null;
|
|
73
|
+
|
|
74
|
+
// Strategy 1: Direct match in static logos
|
|
75
|
+
logoUrl = findInStaticLogos(serverName);
|
|
76
|
+
if (logoUrl) {
|
|
77
|
+
logoCache.set(cacheKey, logoUrl);
|
|
78
|
+
return logoUrl;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Strategy 2: Extract service name from server config
|
|
82
|
+
const serviceName = extractServiceName(serverName, serverConfig);
|
|
83
|
+
if (serviceName && serviceName !== serverName.toLowerCase()) {
|
|
84
|
+
logoUrl = findInStaticLogos(serviceName);
|
|
85
|
+
if (logoUrl) {
|
|
86
|
+
logoCache.set(cacheKey, logoUrl);
|
|
87
|
+
return logoUrl;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Strategy 3: Try logo.dev API for known domains (higher quality logos)
|
|
92
|
+
const domain = guessDomain(serviceName || serverName);
|
|
93
|
+
if (domain) {
|
|
94
|
+
logoUrl = getLogoDevUrl(domain);
|
|
95
|
+
logoCache.set(cacheKey, logoUrl);
|
|
96
|
+
return logoUrl;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// No logo found
|
|
100
|
+
logoCache.set(cacheKey, null);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Find logo in static logos with fuzzy matching
|
|
106
|
+
*/
|
|
107
|
+
function findInStaticLogos(name) {
|
|
108
|
+
if (!staticLogos) return null;
|
|
109
|
+
|
|
110
|
+
const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
111
|
+
|
|
112
|
+
// Exact match
|
|
113
|
+
if (staticLogos[normalized]) return staticLogos[normalized];
|
|
114
|
+
if (staticLogos[name.toLowerCase()]) return staticLogos[name.toLowerCase()];
|
|
115
|
+
|
|
116
|
+
// Partial match - check if any key is contained in name or vice versa
|
|
117
|
+
for (const [key, url] of Object.entries(staticLogos)) {
|
|
118
|
+
if (normalized.includes(key) || key.includes(normalized)) {
|
|
119
|
+
return url;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Extract service name from server name or config
|
|
128
|
+
*/
|
|
129
|
+
function extractServiceName(serverName, serverConfig) {
|
|
130
|
+
const name = serverName.toLowerCase();
|
|
131
|
+
|
|
132
|
+
// Remove common suffixes/prefixes
|
|
133
|
+
let cleaned = name
|
|
134
|
+
.replace(/[-_]?(mcp|server|client|local|remote|dev|prod)[-_]?/g, '')
|
|
135
|
+
.replace(/^@[^/]+\//, '') // Remove npm scope
|
|
136
|
+
.replace(/[-_]+/g, '-')
|
|
137
|
+
.replace(/^-|-$/g, '')
|
|
138
|
+
.trim();
|
|
139
|
+
|
|
140
|
+
if (cleaned) return cleaned;
|
|
141
|
+
|
|
142
|
+
// Try to extract from npm package in config
|
|
143
|
+
if (serverConfig) {
|
|
144
|
+
const args = serverConfig.args || [];
|
|
145
|
+
const command = serverConfig.command || '';
|
|
146
|
+
|
|
147
|
+
// Check for npx package names
|
|
148
|
+
for (const arg of args) {
|
|
149
|
+
// Match patterns like @modelcontextprotocol/server-github
|
|
150
|
+
const match = arg.match(/@[^/]+\/(?:server-|mcp-)?([a-z0-9-]+)/i) ||
|
|
151
|
+
arg.match(/(?:server-|mcp-)([a-z0-9-]+)/i);
|
|
152
|
+
if (match) {
|
|
153
|
+
return match[1].toLowerCase();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check command itself
|
|
158
|
+
const cmdMatch = command.match(/(?:server-|mcp-)([a-z0-9-]+)/i);
|
|
159
|
+
if (cmdMatch) {
|
|
160
|
+
return cmdMatch[1].toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return cleaned || name;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build logo.dev URL for a domain
|
|
169
|
+
* @param {string} domain - The domain to get logo for
|
|
170
|
+
* @param {object} options - Optional parameters (size, format, grayscale)
|
|
171
|
+
* @returns {string} Logo.dev URL
|
|
172
|
+
*/
|
|
173
|
+
function getLogoDevUrl(domain, options = {}) {
|
|
174
|
+
const params = new URLSearchParams({
|
|
175
|
+
token: LOGO_DEV_API_KEY,
|
|
176
|
+
size: options.size || 64,
|
|
177
|
+
format: options.format || 'png'
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (options.grayscale) {
|
|
181
|
+
params.append('greyscale', 'true');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return `${LOGO_DEV_BASE_URL}/${domain}?${params.toString()}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Guess domain from service name
|
|
189
|
+
*/
|
|
190
|
+
function guessDomain(serviceName) {
|
|
191
|
+
const name = serviceName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
192
|
+
|
|
193
|
+
// Names that should NOT auto-detect (too ambiguous or wrong matches)
|
|
194
|
+
const skipAutoDetect = new Set([
|
|
195
|
+
'chrome', 'chromedevtools', 'devtools', // Would incorrectly show Google
|
|
196
|
+
'server', 'mcp', 'local', 'test', 'dev', 'prod', // Too generic
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
if (skipAutoDetect.has(name)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Known mappings
|
|
204
|
+
const domainMap = {
|
|
205
|
+
'github': 'github.com',
|
|
206
|
+
'gitlab': 'gitlab.com',
|
|
207
|
+
'bitbucket': 'bitbucket.org',
|
|
208
|
+
'notion': 'notion.so',
|
|
209
|
+
'slack': 'slack.com',
|
|
210
|
+
'discord': 'discord.com',
|
|
211
|
+
'linear': 'linear.app',
|
|
212
|
+
'asana': 'asana.com',
|
|
213
|
+
'jira': 'atlassian.com',
|
|
214
|
+
'confluence': 'atlassian.com',
|
|
215
|
+
'trello': 'trello.com',
|
|
216
|
+
'airtable': 'airtable.com',
|
|
217
|
+
'coda': 'coda.io',
|
|
218
|
+
'figma': 'figma.com',
|
|
219
|
+
'miro': 'miro.com',
|
|
220
|
+
'stripe': 'stripe.com',
|
|
221
|
+
'paypal': 'paypal.com',
|
|
222
|
+
'square': 'squareup.com',
|
|
223
|
+
'shopify': 'shopify.com',
|
|
224
|
+
'vercel': 'vercel.com',
|
|
225
|
+
'netlify': 'netlify.com',
|
|
226
|
+
'cloudflare': 'cloudflare.com',
|
|
227
|
+
'aws': 'aws.amazon.com',
|
|
228
|
+
'gcp': 'cloud.google.com',
|
|
229
|
+
'azure': 'azure.microsoft.com',
|
|
230
|
+
'supabase': 'supabase.com',
|
|
231
|
+
'firebase': 'firebase.google.com',
|
|
232
|
+
'mongodb': 'mongodb.com',
|
|
233
|
+
'postgres': 'postgresql.org',
|
|
234
|
+
'postgresql': 'postgresql.org',
|
|
235
|
+
'mysql': 'mysql.com',
|
|
236
|
+
'redis': 'redis.io',
|
|
237
|
+
'elasticsearch': 'elastic.co',
|
|
238
|
+
'docker': 'docker.com',
|
|
239
|
+
'kubernetes': 'kubernetes.io',
|
|
240
|
+
'sentry': 'sentry.io',
|
|
241
|
+
'datadog': 'datadoghq.com',
|
|
242
|
+
'grafana': 'grafana.com',
|
|
243
|
+
'prometheus': 'prometheus.io',
|
|
244
|
+
'openai': 'openai.com',
|
|
245
|
+
'anthropic': 'anthropic.com',
|
|
246
|
+
'huggingface': 'huggingface.co',
|
|
247
|
+
'google': 'google.com',
|
|
248
|
+
'brave': 'brave.com',
|
|
249
|
+
'twitter': 'twitter.com',
|
|
250
|
+
'x': 'x.com',
|
|
251
|
+
'linkedin': 'linkedin.com',
|
|
252
|
+
'facebook': 'facebook.com',
|
|
253
|
+
'instagram': 'instagram.com',
|
|
254
|
+
'youtube': 'youtube.com',
|
|
255
|
+
'spotify': 'spotify.com',
|
|
256
|
+
'twilio': 'twilio.com',
|
|
257
|
+
'sendgrid': 'sendgrid.com',
|
|
258
|
+
'mailchimp': 'mailchimp.com',
|
|
259
|
+
'hubspot': 'hubspot.com',
|
|
260
|
+
'salesforce': 'salesforce.com',
|
|
261
|
+
'zendesk': 'zendesk.com',
|
|
262
|
+
'intercom': 'intercom.com',
|
|
263
|
+
'zapier': 'zapier.com',
|
|
264
|
+
'ifttt': 'ifttt.com',
|
|
265
|
+
'dropbox': 'dropbox.com',
|
|
266
|
+
'box': 'box.com',
|
|
267
|
+
'gdrive': 'drive.google.com',
|
|
268
|
+
'onedrive': 'onedrive.live.com',
|
|
269
|
+
'evernote': 'evernote.com',
|
|
270
|
+
'todoist': 'todoist.com',
|
|
271
|
+
'monday': 'monday.com',
|
|
272
|
+
'clickup': 'clickup.com',
|
|
273
|
+
'basecamp': 'basecamp.com',
|
|
274
|
+
'npm': 'npmjs.com',
|
|
275
|
+
'yarn': 'yarnpkg.com',
|
|
276
|
+
'pnpm': 'pnpm.io',
|
|
277
|
+
'pip': 'pypi.org',
|
|
278
|
+
'python': 'python.org',
|
|
279
|
+
'node': 'nodejs.org',
|
|
280
|
+
'deno': 'deno.land',
|
|
281
|
+
'rust': 'rust-lang.org',
|
|
282
|
+
'go': 'go.dev',
|
|
283
|
+
'java': 'java.com',
|
|
284
|
+
'kotlin': 'kotlinlang.org',
|
|
285
|
+
'swift': 'swift.org',
|
|
286
|
+
'typescript': 'typescriptlang.org',
|
|
287
|
+
'webpack': 'webpack.js.org',
|
|
288
|
+
'vite': 'vitejs.dev',
|
|
289
|
+
'esbuild': 'esbuild.github.io',
|
|
290
|
+
'rollup': 'rollupjs.org',
|
|
291
|
+
'babel': 'babeljs.io',
|
|
292
|
+
'eslint': 'eslint.org',
|
|
293
|
+
'prettier': 'prettier.io',
|
|
294
|
+
'jest': 'jestjs.io',
|
|
295
|
+
'mocha': 'mochajs.org',
|
|
296
|
+
'cypress': 'cypress.io',
|
|
297
|
+
'playwright': 'playwright.dev',
|
|
298
|
+
'puppeteer': 'pptr.dev',
|
|
299
|
+
'selenium': 'selenium.dev',
|
|
300
|
+
'react': 'react.dev',
|
|
301
|
+
'vue': 'vuejs.org',
|
|
302
|
+
'angular': 'angular.io',
|
|
303
|
+
'svelte': 'svelte.dev',
|
|
304
|
+
'nextjs': 'nextjs.org',
|
|
305
|
+
'nuxt': 'nuxt.com',
|
|
306
|
+
'gatsby': 'gatsbyjs.com',
|
|
307
|
+
'remix': 'remix.run',
|
|
308
|
+
'astro': 'astro.build',
|
|
309
|
+
'tailwind': 'tailwindcss.com',
|
|
310
|
+
'bootstrap': 'getbootstrap.com',
|
|
311
|
+
'material': 'material.io',
|
|
312
|
+
'chakra': 'chakra-ui.com',
|
|
313
|
+
'neon': 'neon.tech',
|
|
314
|
+
'prisma': 'prisma.io',
|
|
315
|
+
'drizzle': 'orm.drizzle.team',
|
|
316
|
+
'sequelize': 'sequelize.org',
|
|
317
|
+
'typeorm': 'typeorm.io',
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (domainMap[name]) {
|
|
321
|
+
return domainMap[name];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Try common TLDs
|
|
325
|
+
if (name.length >= 3) {
|
|
326
|
+
return `${name}.com`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get initials for fallback display
|
|
334
|
+
*/
|
|
335
|
+
export function getInitials(name) {
|
|
336
|
+
return name
|
|
337
|
+
.split(/[\s-_]+/)
|
|
338
|
+
.slice(0, 2)
|
|
339
|
+
.map(word => word.charAt(0).toUpperCase())
|
|
340
|
+
.join('');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Create logo HTML element with fallback
|
|
345
|
+
*/
|
|
346
|
+
export function createLogoElement(logoUrl, name, size = 40) {
|
|
347
|
+
const initials = getInitials(name);
|
|
348
|
+
|
|
349
|
+
if (logoUrl) {
|
|
350
|
+
return `
|
|
351
|
+
<img src="${logoUrl}" alt="${name}" class="server-logo" style="width:${size}px;height:${size}px"
|
|
352
|
+
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">
|
|
353
|
+
<div class="server-logo-fallback" style="display:none;width:${size}px;height:${size}px">${initials}</div>
|
|
354
|
+
`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return `<div class="server-logo-fallback" style="width:${size}px;height:${size}px">${initials}</div>`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Preload logos for a list of servers
|
|
362
|
+
*/
|
|
363
|
+
export async function preloadLogos(servers) {
|
|
364
|
+
await loadStaticLogos();
|
|
365
|
+
const promises = servers.map(server =>
|
|
366
|
+
getServerLogo(server.name || server.id, server.config || server)
|
|
367
|
+
);
|
|
368
|
+
await Promise.all(promises);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Get logo URL for a domain using logo.dev
|
|
373
|
+
* Useful for custom icon lookup
|
|
374
|
+
* @param {string} domain - Domain like 'github.com'
|
|
375
|
+
* @param {object} options - Optional: size, format, grayscale
|
|
376
|
+
* @returns {string} Logo URL
|
|
377
|
+
*/
|
|
378
|
+
export function getLogoByDomain(domain, options = {}) {
|
|
379
|
+
return getLogoDevUrl(domain, options);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Clear logo cache (useful when preferences change)
|
|
384
|
+
* @param {string} serverName - Optional: clear specific server, or all if not provided
|
|
385
|
+
*/
|
|
386
|
+
export function clearLogoCache(serverName = null) {
|
|
387
|
+
if (serverName) {
|
|
388
|
+
logoCache.delete(serverName.toLowerCase());
|
|
389
|
+
} else {
|
|
390
|
+
logoCache.clear();
|
|
391
|
+
}
|
|
392
|
+
}
|
package/public/js/main.js
CHANGED
|
@@ -2,7 +2,8 @@ import { detectClientsApi, listClientsApi } from './api.js';
|
|
|
2
2
|
import { initClientView, renderClientList, selectClient, loadClientServers } from './clientView.js';
|
|
3
3
|
import { initKanbanView, renderKanbanGrid } from './kanbanView.js';
|
|
4
4
|
import { initServerView, renderAllServers } from './serverView.js';
|
|
5
|
-
import { showServerModal, showImportModal, exportConfig, initModals, showRemoteServerModal } from './modals.js';
|
|
5
|
+
import { showServerModal, showImportModal, exportConfig, initModals, showRemoteServerModal, showSelectClientModal } from './modals.js';
|
|
6
|
+
import { showPopularMcpsModal, closePopularMcpsModal } from './popularMcps.js';
|
|
6
7
|
|
|
7
8
|
document.addEventListener('DOMContentLoaded', () => {
|
|
8
9
|
let currentClient = null;
|
|
@@ -101,6 +102,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
101
102
|
});
|
|
102
103
|
document.getElementById('exportConfigBtn').addEventListener('click', exportConfig);
|
|
103
104
|
document.getElementById('importConfigBtn').addEventListener('click', () => showImportModal(loadClientServers, renderKanbanGrid, window.loadClients));
|
|
105
|
+
document.getElementById('browsePopularMcpsBtn').addEventListener('click', () => {
|
|
106
|
+
showPopularMcpsModal(clients, window.loadClients);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Kanban view add server button
|
|
110
|
+
document.getElementById('kanbanAddServerBtn').addEventListener('click', () => {
|
|
111
|
+
showSelectClientModal((selectedClientId) => {
|
|
112
|
+
showServerModal(null, null, loadClientServers, renderKanbanGrid, selectedClientId, window.loadClients);
|
|
113
|
+
}, window.loadClients);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Server view add server button
|
|
117
|
+
document.getElementById('serverViewAddServerBtn').addEventListener('click', () => {
|
|
118
|
+
showSelectClientModal((selectedClientId) => {
|
|
119
|
+
showServerModal(null, null, renderAllServers, null, selectedClientId, window.loadClients);
|
|
120
|
+
}, window.loadClients);
|
|
121
|
+
});
|
|
104
122
|
|
|
105
123
|
// Close modals when clicking outside
|
|
106
124
|
document.querySelectorAll('.modal').forEach(modal => {
|
|
@@ -131,6 +149,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
131
149
|
document.getElementById('addServerToClientsModal').style.display = 'none';
|
|
132
150
|
});
|
|
133
151
|
|
|
152
|
+
document.getElementById('closePopularMcps').addEventListener('click', closePopularMcpsModal);
|
|
153
|
+
|
|
134
154
|
// Load clients on page load
|
|
135
155
|
loadClients();
|
|
136
156
|
});
|