@adityanair98/api-oracle 0.5.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/LICENSE +21 -0
- package/README.md +216 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +74 -0
- package/dist/dashboard/public/app.js +1004 -0
- package/dist/dashboard/public/index.html +142 -0
- package/dist/dashboard/public/public/app.js +1004 -0
- package/dist/dashboard/public/public/index.html +142 -0
- package/dist/dashboard/public/public/styles.css +1464 -0
- package/dist/dashboard/public/styles.css +1464 -0
- package/dist/dashboard/routes/api.d.ts +7 -0
- package/dist/dashboard/routes/api.js +245 -0
- package/dist/dashboard/server.d.ts +9 -0
- package/dist/dashboard/server.js +45 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +23 -0
- package/dist/knowledge/db.d.ts +22 -0
- package/dist/knowledge/db.js +182 -0
- package/dist/knowledge/schema.d.ts +275 -0
- package/dist/knowledge/schema.js +135 -0
- package/dist/knowledge/scorer.d.ts +63 -0
- package/dist/knowledge/scorer.js +314 -0
- package/dist/knowledge/search.d.ts +37 -0
- package/dist/knowledge/search.js +111 -0
- package/dist/knowledge/synonyms.d.ts +36 -0
- package/dist/knowledge/synonyms.js +523 -0
- package/dist/knowledge/tfidf.d.ts +42 -0
- package/dist/knowledge/tfidf.js +138 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +40 -0
- package/dist/tools/check-freshness.d.ts +9 -0
- package/dist/tools/check-freshness.js +95 -0
- package/dist/tools/compare-apis.d.ts +8 -0
- package/dist/tools/compare-apis.js +149 -0
- package/dist/tools/find-api.d.ts +9 -0
- package/dist/tools/find-api.js +120 -0
- package/dist/tools/get-setup-guide.d.ts +8 -0
- package/dist/tools/get-setup-guide.js +127 -0
- package/dist/updater/linter.d.ts +31 -0
- package/dist/updater/linter.js +219 -0
- package/dist/updater/report.d.ts +29 -0
- package/dist/updater/report.js +96 -0
- package/dist/updater/staleness.d.ts +39 -0
- package/dist/updater/staleness.js +66 -0
- package/dist/updater/version-tracker.d.ts +28 -0
- package/dist/updater/version-tracker.js +50 -0
- package/dist/utils/config.d.ts +11 -0
- package/dist/utils/config.js +13 -0
- package/dist/utils/logger.d.ts +20 -0
- package/dist/utils/logger.js +32 -0
- package/package.json +56 -0
- package/src/entries/ai/anthropic.json +95 -0
- package/src/entries/ai/eleven-labs.json +90 -0
- package/src/entries/ai/openai.json +95 -0
- package/src/entries/ai/replicate.json +87 -0
- package/src/entries/ai/resemble-ai.json +88 -0
- package/src/entries/ai/stability-ai.json +89 -0
- package/src/entries/analytics/posthog.json +88 -0
- package/src/entries/analytics/sentry.json +84 -0
- package/src/entries/auth/auth0.json +90 -0
- package/src/entries/auth/clerk.json +95 -0
- package/src/entries/cms/contentful.json +92 -0
- package/src/entries/cms/sanity.json +92 -0
- package/src/entries/cms/strapi.json +93 -0
- package/src/entries/commerce/medusa.json +91 -0
- package/src/entries/commerce/shopify-api.json +91 -0
- package/src/entries/communication/sendbird.json +85 -0
- package/src/entries/communication/stream-chat.json +94 -0
- package/src/entries/database/firebase.json +88 -0
- package/src/entries/database/neon.json +94 -0
- package/src/entries/database/planetscale.json +95 -0
- package/src/entries/database/supabase.json +94 -0
- package/src/entries/database/upstash.json +94 -0
- package/src/entries/devops/fly-io.json +90 -0
- package/src/entries/devops/netlify.json +90 -0
- package/src/entries/devops/railway.json +90 -0
- package/src/entries/devops/vercel.json +90 -0
- package/src/entries/email/mailgun.json +91 -0
- package/src/entries/email/postmark.json +91 -0
- package/src/entries/email/resend.json +89 -0
- package/src/entries/email/sendgrid.json +90 -0
- package/src/entries/forms/formspark.json +85 -0
- package/src/entries/forms/typeform.json +98 -0
- package/src/entries/infrastructure/aws-s3.json +104 -0
- package/src/entries/infrastructure/cloudflare-r2.json +92 -0
- package/src/entries/infrastructure/cloudflare-workers.json +92 -0
- package/src/entries/infrastructure/digital-ocean-spaces.json +87 -0
- package/src/entries/integration/nango.json +90 -0
- package/src/entries/integration/zapier.json +92 -0
- package/src/entries/maps/google-maps.json +89 -0
- package/src/entries/maps/mapbox.json +87 -0
- package/src/entries/media/deepgram.json +84 -0
- package/src/entries/media/imgix.json +84 -0
- package/src/entries/media/mux.json +94 -0
- package/src/entries/messaging/ably.json +94 -0
- package/src/entries/messaging/pusher.json +94 -0
- package/src/entries/messaging/twilio.json +94 -0
- package/src/entries/messaging/vonage.json +89 -0
- package/src/entries/notifications/knock.json +84 -0
- package/src/entries/notifications/novu.json +84 -0
- package/src/entries/notifications/onesignal.json +84 -0
- package/src/entries/payments/lemonsqueezy.json +91 -0
- package/src/entries/payments/paddle.json +90 -0
- package/src/entries/payments/paypal.json +91 -0
- package/src/entries/payments/razorpay.json +85 -0
- package/src/entries/payments/square.json +91 -0
- package/src/entries/payments/stripe.json +96 -0
- package/src/entries/scheduling/cal-com.json +90 -0
- package/src/entries/scheduling/calendly.json +90 -0
- package/src/entries/search/algolia.json +96 -0
- package/src/entries/security/arcjet.json +89 -0
- package/src/entries/security/snyk.json +90 -0
- package/src/entries/storage/cloudinary.json +93 -0
- package/src/entries/storage/uploadthing.json +90 -0
- package/src/entries/testing/browserstack.json +86 -0
- package/src/entries/testing/checkly.json +89 -0
- package/src/entries/workflow/inngest.json +88 -0
- package/src/entries/workflow/temporal.json +90 -0
- package/src/entries/workflow/trigger-dev.json +89 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
// API Oracle Dashboard — app.js
|
|
2
|
+
// Vanilla JS, no framework, no CDN dependencies.
|
|
3
|
+
|
|
4
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const state = {
|
|
7
|
+
entries: [],
|
|
8
|
+
categories: [],
|
|
9
|
+
stats: null,
|
|
10
|
+
filtered: [],
|
|
11
|
+
activeCategory: 'all',
|
|
12
|
+
activeView: 'dashboard',
|
|
13
|
+
searchQuery: '',
|
|
14
|
+
sortValue: 'qualityScore-desc',
|
|
15
|
+
loading: false,
|
|
16
|
+
viewsLoaded: new Set(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// ─── Category Color Helper ─────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function getCategoryColor(category) {
|
|
22
|
+
const colors = {
|
|
23
|
+
email: '#3B82F6', messaging: '#3B82F6', communication: '#3B82F6', notifications: '#3B82F6',
|
|
24
|
+
payments: '#059669', commerce: '#059669',
|
|
25
|
+
ai: '#7C3AED', media: '#7C3AED',
|
|
26
|
+
database: '#EA580C', storage: '#EA580C', infrastructure: '#EA580C',
|
|
27
|
+
auth: '#DC2626', security: '#DC2626',
|
|
28
|
+
devops: '#475569', testing: '#475569', workflow: '#475569', integration: '#475569',
|
|
29
|
+
maps: '#0891B2', search: '#0891B2', analytics: '#0891B2',
|
|
30
|
+
cms: '#DB2777', forms: '#DB2777', scheduling: '#DB2777',
|
|
31
|
+
};
|
|
32
|
+
return colors[category] || '#6B7280';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Staleness Helpers ─────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function getStalenessColor(level) {
|
|
38
|
+
const map = { fresh: '#22C55E', aging: '#EAB308', stale: '#F97316', critical: '#DC2626' };
|
|
39
|
+
return map[level] || '#6B7280';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getStalenessLabel(level) {
|
|
43
|
+
const map = { fresh: 'Fresh', aging: 'Aging', stale: 'Stale', critical: 'Critical' };
|
|
44
|
+
return map[level] || level;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getStalenessEmoji(level) {
|
|
48
|
+
const map = { fresh: '✅', aging: '🟡', stale: '🟠', critical: '🔴' };
|
|
49
|
+
return map[level] || '⚪';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getStalenessBadge(level) {
|
|
53
|
+
const cls = { fresh: 'badge-green', aging: 'badge-yellow', stale: 'badge-orange', critical: 'badge-red' };
|
|
54
|
+
return cls[level] || 'badge-gray';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Score Helpers ─────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function renderScoreDots(score) {
|
|
60
|
+
const filled = Math.round(score);
|
|
61
|
+
const cls = score >= 8 ? 'score-high' : score >= 6 ? 'score-mid' : 'score-low';
|
|
62
|
+
let dots = '';
|
|
63
|
+
for (let i = 1; i <= 10; i++) {
|
|
64
|
+
dots += i <= filled ? '●' : '○';
|
|
65
|
+
}
|
|
66
|
+
return `<span class="score-dots ${cls}">${dots}</span>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scoreBarClass(value) {
|
|
70
|
+
if (value >= 0.7) return 'high';
|
|
71
|
+
if (value >= 0.4) return 'medium';
|
|
72
|
+
return 'low';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Toast ─────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function showToast(message, type = 'success') {
|
|
78
|
+
let container = document.getElementById('toast-container');
|
|
79
|
+
if (!container) {
|
|
80
|
+
container = document.createElement('div');
|
|
81
|
+
container.id = 'toast-container';
|
|
82
|
+
document.body.appendChild(container);
|
|
83
|
+
}
|
|
84
|
+
const toast = document.createElement('div');
|
|
85
|
+
toast.className = `toast toast-${type}`;
|
|
86
|
+
toast.textContent = message;
|
|
87
|
+
container.appendChild(toast);
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
toast.style.opacity = '0';
|
|
90
|
+
toast.style.transform = 'translateX(20px)';
|
|
91
|
+
toast.style.transition = 'opacity 0.25s ease, transform 0.25s ease';
|
|
92
|
+
setTimeout(() => toast.remove(), 280);
|
|
93
|
+
}, 3000);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Fetch Helpers ─────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
async function apiFetch(path) {
|
|
99
|
+
const res = await fetch(path);
|
|
100
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
101
|
+
const json = await res.json();
|
|
102
|
+
if (!json.success) throw new Error(json.error || 'Unknown API error');
|
|
103
|
+
return json.data;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function apiPost(path) {
|
|
107
|
+
const res = await fetch(path, { method: 'POST' });
|
|
108
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
109
|
+
const json = await res.json();
|
|
110
|
+
if (!json.success) throw new Error(json.error || 'Unknown API error');
|
|
111
|
+
return json.data;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Sorting ───────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function sortEntries(entries, sortValue) {
|
|
117
|
+
const [field, order] = sortValue.split('-');
|
|
118
|
+
const dir = order === 'asc' ? 1 : -1;
|
|
119
|
+
return [...entries].sort((a, b) => {
|
|
120
|
+
switch (field) {
|
|
121
|
+
case 'qualityScore':
|
|
122
|
+
return (a.qualityScore - b.qualityScore) * dir;
|
|
123
|
+
case 'name':
|
|
124
|
+
return a.name.localeCompare(b.name) * dir;
|
|
125
|
+
case 'lastVerified':
|
|
126
|
+
return a.lastVerified.localeCompare(b.lastVerified) * dir;
|
|
127
|
+
case 'staleness': {
|
|
128
|
+
const levels = { fresh: 0, aging: 1, stale: 2, critical: 3 };
|
|
129
|
+
const la = levels[a.staleness?.level ?? 'fresh'];
|
|
130
|
+
const lb = levels[b.staleness?.level ?? 'fresh'];
|
|
131
|
+
return (la - lb) * dir;
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Category Sidebar ──────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
function renderCategoryList() {
|
|
142
|
+
const container = document.getElementById('category-list');
|
|
143
|
+
if (!container) return;
|
|
144
|
+
|
|
145
|
+
let html = `<a class="category-item ${state.activeCategory === 'all' ? 'active' : ''}" data-category="all" href="#category=all">
|
|
146
|
+
All <span class="category-count" id="cat-count-all">${state.entries.length}</span>
|
|
147
|
+
</a>`;
|
|
148
|
+
|
|
149
|
+
for (const { category, count } of state.categories) {
|
|
150
|
+
const active = state.activeCategory === category ? 'active' : '';
|
|
151
|
+
html += `<a class="category-item ${active}" data-category="${category}" href="#category=${category}">
|
|
152
|
+
${capitalize(category)} <span class="category-count">${count}</span>
|
|
153
|
+
</a>`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
container.innerHTML = html;
|
|
157
|
+
|
|
158
|
+
container.querySelectorAll('.category-item').forEach(el => {
|
|
159
|
+
el.addEventListener('click', e => {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
const cat = el.dataset.category;
|
|
162
|
+
selectCategory(cat);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function selectCategory(category) {
|
|
168
|
+
state.activeCategory = category;
|
|
169
|
+
updateHash();
|
|
170
|
+
filterAndRender();
|
|
171
|
+
renderCategoryList();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Stats Bar ─────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
function renderStats() {
|
|
177
|
+
if (!state.stats) return;
|
|
178
|
+
const s = state.stats;
|
|
179
|
+
setText('stat-total', s.totalEntries);
|
|
180
|
+
setText('stat-categories', s.categories);
|
|
181
|
+
setText('stat-fresh', s.staleness.fresh);
|
|
182
|
+
setText('stat-aging', s.staleness.aging);
|
|
183
|
+
setText('stat-stale', (s.staleness.stale || 0) + (s.staleness.critical || 0));
|
|
184
|
+
setText('stat-avg-score', s.averageQualityScore.toFixed(1));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function setText(id, val) {
|
|
188
|
+
const el = document.getElementById(id);
|
|
189
|
+
if (el) el.textContent = String(val);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Filter + Render Grid ──────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
function filterAndRender() {
|
|
195
|
+
let entries = state.entries;
|
|
196
|
+
|
|
197
|
+
if (state.activeCategory !== 'all') {
|
|
198
|
+
entries = entries.filter(e => e.category === state.activeCategory);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!state.searchQuery) {
|
|
202
|
+
entries = sortEntries(entries, state.sortValue);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
state.filtered = entries;
|
|
206
|
+
|
|
207
|
+
const countEl = document.getElementById('results-count');
|
|
208
|
+
if (countEl) {
|
|
209
|
+
countEl.textContent = `${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
renderGrid(entries);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function renderGrid(entries) {
|
|
216
|
+
const grid = document.getElementById('entries-grid');
|
|
217
|
+
if (!grid) return;
|
|
218
|
+
|
|
219
|
+
if (entries.length === 0) {
|
|
220
|
+
grid.innerHTML = `<div class="empty-state">
|
|
221
|
+
<span class="empty-state-icon">🔍</span>
|
|
222
|
+
<div class="empty-state-title">No entries found</div>
|
|
223
|
+
<div class="empty-state-subtitle">Try a different search term or category.</div>
|
|
224
|
+
</div>`;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
grid.innerHTML = entries.map(e => renderCard(e)).join('');
|
|
229
|
+
|
|
230
|
+
grid.querySelectorAll('.view-details-btn').forEach(btn => {
|
|
231
|
+
btn.addEventListener('click', () => openModal(btn.dataset.slug));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderCard(entry) {
|
|
236
|
+
const color = getCategoryColor(entry.category);
|
|
237
|
+
const level = entry.staleness?.level || 'fresh';
|
|
238
|
+
const days = entry.staleness?.daysSinceVerified ?? 0;
|
|
239
|
+
const bestFor = Array.isArray(entry.bestFor) ? entry.bestFor[0] : entry.bestFor;
|
|
240
|
+
|
|
241
|
+
return `<article class="entry-card">
|
|
242
|
+
<div class="card-top">
|
|
243
|
+
<div class="card-badges">
|
|
244
|
+
<span class="category-badge" style="background:${color}">${entry.category}</span>
|
|
245
|
+
${entry.pricing?.freeTier ? '<span class="badge badge-green">Free tier</span>' : ''}
|
|
246
|
+
</div>
|
|
247
|
+
<span class="staleness-dot ${level}" title="${getStalenessLabel(level)} — ${days}d ago"></span>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="card-name">${escapeHtml(entry.name)}</div>
|
|
250
|
+
<div class="card-description">${escapeHtml(entry.description)}</div>
|
|
251
|
+
<div class="card-meta">
|
|
252
|
+
<div class="card-score-row">
|
|
253
|
+
${renderScoreDots(entry.qualityScore)}
|
|
254
|
+
<span class="score-num">${entry.qualityScore}/10</span>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
${bestFor ? `<div class="card-bestfor"><strong>Best for:</strong> ${escapeHtml(bestFor)}</div>` : ''}
|
|
258
|
+
<div class="card-footer">
|
|
259
|
+
<button class="btn btn-sm btn-outline btn-full view-details-btn" data-slug="${entry.slug}">View Details</button>
|
|
260
|
+
</div>
|
|
261
|
+
</article>`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Search ────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
let searchDebounceTimer = null;
|
|
267
|
+
|
|
268
|
+
function onSearchInput(query) {
|
|
269
|
+
const clearBtn = document.getElementById('search-clear');
|
|
270
|
+
if (clearBtn) clearBtn.hidden = !query.trim();
|
|
271
|
+
|
|
272
|
+
state.searchQuery = query.trim();
|
|
273
|
+
updateHash();
|
|
274
|
+
|
|
275
|
+
clearTimeout(searchDebounceTimer);
|
|
276
|
+
if (!state.searchQuery) {
|
|
277
|
+
filterAndRender();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
searchDebounceTimer = setTimeout(() => performSearch(state.searchQuery), 300);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function performSearch(query) {
|
|
285
|
+
const grid = document.getElementById('entries-grid');
|
|
286
|
+
if (grid) grid.innerHTML = '<div class="loading-state">Searching…</div>';
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const data = await apiFetch(`/api/entries?search=${encodeURIComponent(query)}`);
|
|
290
|
+
state.filtered = data;
|
|
291
|
+
|
|
292
|
+
const countEl = document.getElementById('results-count');
|
|
293
|
+
if (countEl) {
|
|
294
|
+
countEl.textContent = `${data.length} result${data.length !== 1 ? 's' : ''} for "${query}"`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (data.length === 0) {
|
|
298
|
+
if (grid) grid.innerHTML = `<div class="empty-state">
|
|
299
|
+
<span class="empty-state-icon">🔍</span>
|
|
300
|
+
<div class="empty-state-title">No results for "${escapeHtml(query)}"</div>
|
|
301
|
+
<div class="empty-state-subtitle">Try different keywords or browse by category.</div>
|
|
302
|
+
</div>`;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
renderGrid(data);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
if (grid) grid.innerHTML = `<div class="empty-state">
|
|
309
|
+
<span class="empty-state-icon">⚠</span>
|
|
310
|
+
<div class="empty-state-title">Search failed</div>
|
|
311
|
+
<div class="empty-state-subtitle">${escapeHtml(e.message)}</div>
|
|
312
|
+
</div>`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Detail Modal ──────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
async function openModal(slug) {
|
|
319
|
+
const overlay = document.getElementById('detail-modal');
|
|
320
|
+
const content = document.getElementById('modal-content');
|
|
321
|
+
if (!overlay || !content) return;
|
|
322
|
+
|
|
323
|
+
content.innerHTML = '<div class="loading-state">Loading…</div>';
|
|
324
|
+
overlay.hidden = false;
|
|
325
|
+
document.body.style.overflow = 'hidden';
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const { entry, staleness, lintResult } = await apiFetch(`/api/entries/${slug}`);
|
|
329
|
+
content.innerHTML = renderModalContent(entry, staleness, lintResult);
|
|
330
|
+
|
|
331
|
+
content.querySelectorAll('.copy-btn').forEach(btn => {
|
|
332
|
+
btn.addEventListener('click', () => {
|
|
333
|
+
const code = btn.dataset.code || btn.closest('.code-block')?.querySelector('pre')?.textContent || '';
|
|
334
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
335
|
+
const orig = btn.textContent;
|
|
336
|
+
btn.textContent = 'Copied!';
|
|
337
|
+
setTimeout(() => { btn.textContent = orig; }, 1500);
|
|
338
|
+
}).catch(() => showToast('Copy failed — clipboard not available', 'error'));
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
content.querySelectorAll('.relationship-link').forEach(link => {
|
|
343
|
+
link.addEventListener('click', e => {
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
openModal(link.dataset.slug);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const verifyBtn = content.querySelector('.verify-entry-btn');
|
|
350
|
+
if (verifyBtn) {
|
|
351
|
+
verifyBtn.addEventListener('click', () => verifyEntry(slug, verifyBtn));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const quickStartBtn = content.querySelector('.copy-quickstart-btn');
|
|
355
|
+
if (quickStartBtn) {
|
|
356
|
+
quickStartBtn.addEventListener('click', () => copyQuickStart(entry));
|
|
357
|
+
}
|
|
358
|
+
} catch (e) {
|
|
359
|
+
content.innerHTML = `<div class="empty-state">
|
|
360
|
+
<span class="empty-state-icon">⚠</span>
|
|
361
|
+
<div class="empty-state-title">Failed to load entry</div>
|
|
362
|
+
<div class="empty-state-subtitle">${escapeHtml(e.message)}</div>
|
|
363
|
+
</div>`;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function closeModal() {
|
|
368
|
+
const overlay = document.getElementById('detail-modal');
|
|
369
|
+
if (overlay) overlay.hidden = true;
|
|
370
|
+
document.body.style.overflow = '';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function renderModalContent(entry, staleness, lintResult) {
|
|
374
|
+
const color = getCategoryColor(entry.category);
|
|
375
|
+
const level = staleness?.level || 'fresh';
|
|
376
|
+
const days = staleness?.daysSinceVerified ?? 0;
|
|
377
|
+
|
|
378
|
+
const useCasesHtml = entry.useCases.map(uc => `
|
|
379
|
+
<div class="use-case-item">
|
|
380
|
+
<span class="use-case-pill ${uc.fit}">${uc.fit}</span>
|
|
381
|
+
<span class="use-case-task">${escapeHtml(uc.task)}</span>
|
|
382
|
+
</div>`).join('');
|
|
383
|
+
|
|
384
|
+
const codeExamplesHtml = entry.codeExamples.map(ex => `
|
|
385
|
+
<div style="margin-bottom:14px">
|
|
386
|
+
<div class="code-block">
|
|
387
|
+
<div class="code-block-header">
|
|
388
|
+
<span class="code-block-title">${escapeHtml(ex.title)} <span style="color:#475569;text-transform:none;letter-spacing:0">(${escapeHtml(ex.language)})</span></span>
|
|
389
|
+
<button class="copy-btn">Copy</button>
|
|
390
|
+
</div>
|
|
391
|
+
<pre>${escapeHtml(ex.code)}</pre>
|
|
392
|
+
</div>
|
|
393
|
+
${ex.notes ? `<p style="font-size:12.5px;color:var(--color-text-muted);margin-top:6px">${escapeHtml(ex.notes)}</p>` : ''}
|
|
394
|
+
</div>`).join('');
|
|
395
|
+
|
|
396
|
+
const gotchasHtml = `<div class="gotcha-list">${
|
|
397
|
+
entry.gotchas.map(g => `<div class="gotcha-card">${escapeHtml(g)}</div>`).join('')
|
|
398
|
+
}</div>`;
|
|
399
|
+
|
|
400
|
+
const altSlugs = (entry.alternatives || []).map(s =>
|
|
401
|
+
`<a class="relationship-link" data-slug="${s}" href="#">${s}</a>`).join('');
|
|
402
|
+
const compSlugs = (entry.complementary || []).map(s =>
|
|
403
|
+
`<a class="relationship-link" data-slug="${s}" href="#">${s}</a>`).join('');
|
|
404
|
+
|
|
405
|
+
const lintWarnings = lintResult && !lintResult.passed
|
|
406
|
+
? `<div style="margin-top:8px;padding:8px 12px;background:#FEF9C3;border-radius:6px;font-size:12.5px;color:#78350F">
|
|
407
|
+
<strong>Lint issues:</strong> ${lintResult.issues.length} — ${lintResult.issues.slice(0, 2).map(i => i.message).join('; ')}${lintResult.issues.length > 2 ? '…' : ''}
|
|
408
|
+
</div>`
|
|
409
|
+
: '';
|
|
410
|
+
|
|
411
|
+
return `
|
|
412
|
+
<div class="modal-header">
|
|
413
|
+
<div class="modal-header-top">
|
|
414
|
+
<h1 class="modal-title">${escapeHtml(entry.name)}</h1>
|
|
415
|
+
<button class="btn btn-sm btn-ghost verify-entry-btn" data-slug="${entry.slug}">Mark Verified</button>
|
|
416
|
+
</div>
|
|
417
|
+
<div class="modal-meta-row">
|
|
418
|
+
<span class="category-badge" style="background:${color}">${entry.category}</span>
|
|
419
|
+
${entry.subcategory ? `<span class="badge badge-gray">${escapeHtml(entry.subcategory)}</span>` : ''}
|
|
420
|
+
<span class="badge ${getStalenessBadge(level)}">${getStalenessEmoji(level)} ${getStalenessLabel(level)} (${days}d)</span>
|
|
421
|
+
${renderScoreDots(entry.qualityScore)}
|
|
422
|
+
<span style="font-size:13px;font-weight:700;color:var(--color-text-muted)">${entry.qualityScore}/10</span>
|
|
423
|
+
</div>
|
|
424
|
+
<p class="modal-description">${escapeHtml(entry.description)}</p>
|
|
425
|
+
${lintWarnings}
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
<div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap">
|
|
429
|
+
<a href="${escapeHtml(entry.website)}" target="_blank" rel="noopener" class="btn btn-sm btn-outline">Website ↗</a>
|
|
430
|
+
${entry.pricing?.pricingUrl ? `<a href="${escapeHtml(entry.pricing.pricingUrl)}" target="_blank" rel="noopener" class="btn btn-sm btn-ghost">Pricing ↗</a>` : ''}
|
|
431
|
+
${entry.reliability?.statusPageUrl ? `<a href="${escapeHtml(entry.reliability.statusPageUrl)}" target="_blank" rel="noopener" class="btn btn-sm btn-ghost">Status ↗</a>` : ''}
|
|
432
|
+
<button class="btn btn-sm btn-ghost copy-quickstart-btn">Copy Quick Start</button>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<div class="modal-section">
|
|
436
|
+
<div class="modal-section-title">Use Cases</div>
|
|
437
|
+
<div class="use-case-list">${useCasesHtml}</div>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
<div class="modal-section">
|
|
441
|
+
<div class="modal-section-title">Quick Start</div>
|
|
442
|
+
<div class="code-block" style="margin-bottom:10px">
|
|
443
|
+
<div class="code-block-header">
|
|
444
|
+
<span class="code-block-title">Install</span>
|
|
445
|
+
<button class="copy-btn">Copy</button>
|
|
446
|
+
</div>
|
|
447
|
+
<pre>${escapeHtml(entry.sdk.installCommand)}</pre>
|
|
448
|
+
</div>
|
|
449
|
+
<div class="code-block" style="margin-bottom:10px">
|
|
450
|
+
<div class="code-block-header">
|
|
451
|
+
<span class="code-block-title">Import</span>
|
|
452
|
+
<button class="copy-btn">Copy</button>
|
|
453
|
+
</div>
|
|
454
|
+
<pre>${escapeHtml(entry.sdk.importStatement)}</pre>
|
|
455
|
+
</div>
|
|
456
|
+
${entry.auth?.envVarName ? `<div class="code-block">
|
|
457
|
+
<div class="code-block-header">
|
|
458
|
+
<span class="code-block-title">Auth — ${escapeHtml(entry.auth.method)}</span>
|
|
459
|
+
<button class="copy-btn">Copy</button>
|
|
460
|
+
</div>
|
|
461
|
+
<pre>${escapeHtml(entry.auth.codeSnippet || `${entry.auth.envVarName}=your_key_here`)}</pre>
|
|
462
|
+
</div>` : ''}
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<div class="modal-section">
|
|
466
|
+
<div class="modal-section-title">Code Examples</div>
|
|
467
|
+
${codeExamplesHtml}
|
|
468
|
+
</div>
|
|
469
|
+
|
|
470
|
+
<div class="modal-section">
|
|
471
|
+
<div class="modal-section-title">Pricing</div>
|
|
472
|
+
<div class="detail-grid">
|
|
473
|
+
<div class="detail-item"><div class="detail-label">Model</div><div class="detail-value">${escapeHtml(entry.pricing.model)}</div></div>
|
|
474
|
+
<div class="detail-item"><div class="detail-label">Free Tier</div><div class="detail-value">${entry.pricing.freeTier ? 'Yes' : 'No'}</div></div>
|
|
475
|
+
${entry.pricing.startingPrice ? `<div class="detail-item"><div class="detail-label">Starting Price</div><div class="detail-value">${escapeHtml(entry.pricing.startingPrice)}</div></div>` : ''}
|
|
476
|
+
${entry.pricing.costPer ? `<div class="detail-item"><div class="detail-label">Cost Per</div><div class="detail-value">${escapeHtml(entry.pricing.costPer)}</div></div>` : ''}
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
|
|
480
|
+
<div class="modal-section">
|
|
481
|
+
<div class="modal-section-title">Rate Limits</div>
|
|
482
|
+
<div class="detail-grid">
|
|
483
|
+
<div class="detail-item"><div class="detail-label">Tier</div><div class="detail-value">${escapeHtml(entry.rateLimits.tier)}</div></div>
|
|
484
|
+
<div class="detail-item"><div class="detail-label">Limit</div><div class="detail-value">${escapeHtml(entry.rateLimits.limit)}</div></div>
|
|
485
|
+
${entry.rateLimits.notes ? `<div class="detail-item" style="grid-column:1/-1"><div class="detail-label">Notes</div><div class="detail-value">${escapeHtml(entry.rateLimits.notes)}</div></div>` : ''}
|
|
486
|
+
${entry.rateLimits.retryStrategy ? `<div class="detail-item" style="grid-column:1/-1"><div class="detail-label">Retry Strategy</div><div class="detail-value">${escapeHtml(entry.rateLimits.retryStrategy)}</div></div>` : ''}
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
|
|
490
|
+
<div class="modal-section">
|
|
491
|
+
<div class="modal-section-title">Technical</div>
|
|
492
|
+
<div class="detail-grid">
|
|
493
|
+
<div class="detail-item"><div class="detail-label">Primary Language</div><div class="detail-value">${escapeHtml(entry.sdk.primaryLanguage)}</div></div>
|
|
494
|
+
${entry.sdk.otherLanguages?.length ? `<div class="detail-item"><div class="detail-label">Other SDKs</div><div class="detail-value">${entry.sdk.otherLanguages.join(', ')}</div></div>` : ''}
|
|
495
|
+
${entry.reliability?.uptimeGuarantee ? `<div class="detail-item"><div class="detail-label">Uptime SLA</div><div class="detail-value">${escapeHtml(entry.reliability.uptimeGuarantee)}</div></div>` : ''}
|
|
496
|
+
${entry.reliability?.notes ? `<div class="detail-item" style="grid-column:1/-1"><div class="detail-label">Reliability Notes</div><div class="detail-value">${escapeHtml(entry.reliability.notes)}</div></div>` : ''}
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
|
|
500
|
+
<div class="modal-section">
|
|
501
|
+
<div class="modal-section-title">Gotchas</div>
|
|
502
|
+
${gotchasHtml}
|
|
503
|
+
</div>
|
|
504
|
+
|
|
505
|
+
${altSlugs || compSlugs ? `<div class="modal-section">
|
|
506
|
+
<div class="modal-section-title">Relationships</div>
|
|
507
|
+
${altSlugs ? `<div style="margin-bottom:10px"><div style="font-size:12px;font-weight:600;color:var(--color-text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em">Alternatives</div><div class="relationship-links">${altSlugs}</div></div>` : ''}
|
|
508
|
+
${compSlugs ? `<div><div style="font-size:12px;font-weight:600;color:var(--color-text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em">Complementary</div><div class="relationship-links">${compSlugs}</div></div>` : ''}
|
|
509
|
+
</div>` : ''}
|
|
510
|
+
|
|
511
|
+
<div class="modal-section">
|
|
512
|
+
<div class="modal-section-title">Quality</div>
|
|
513
|
+
<div class="detail-grid">
|
|
514
|
+
<div class="detail-item"><div class="detail-label">Score</div><div class="detail-value">${entry.qualityScore}/10</div></div>
|
|
515
|
+
<div class="detail-item" style="grid-column:1/-1"><div class="detail-label">Justification</div><div class="detail-value">${escapeHtml(entry.qualityJustification)}</div></div>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
<div class="modal-section">
|
|
520
|
+
<div class="modal-section-title">Metadata</div>
|
|
521
|
+
<div class="detail-grid">
|
|
522
|
+
<div class="detail-item"><div class="detail-label">Slug</div><div class="detail-value text-mono">${escapeHtml(entry.slug)}</div></div>
|
|
523
|
+
<div class="detail-item"><div class="detail-label">Last Verified</div><div class="detail-value">${escapeHtml(entry.lastVerified)}</div></div>
|
|
524
|
+
<div class="detail-item"><div class="detail-label">Entry Version</div><div class="detail-value">${escapeHtml(String(entry.entryVersion))}</div></div>
|
|
525
|
+
<div class="detail-item"><div class="detail-label">Added By</div><div class="detail-value">${escapeHtml(entry.addedBy)}</div></div>
|
|
526
|
+
</div>
|
|
527
|
+
</div>`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function copyQuickStart(entry) {
|
|
531
|
+
const firstExample = entry.codeExamples?.[0];
|
|
532
|
+
const parts = [
|
|
533
|
+
`# Install`,
|
|
534
|
+
entry.sdk.installCommand,
|
|
535
|
+
``,
|
|
536
|
+
`# Environment`,
|
|
537
|
+
entry.auth?.envVarName ? `${entry.auth.envVarName}=your_key_here` : '',
|
|
538
|
+
``,
|
|
539
|
+
`# Example: ${firstExample?.title || 'Usage'}`,
|
|
540
|
+
firstExample?.code || '',
|
|
541
|
+
].filter(line => line !== null && line !== undefined);
|
|
542
|
+
|
|
543
|
+
navigator.clipboard.writeText(parts.join('\n')).then(() => {
|
|
544
|
+
showToast('Quick Start copied to clipboard!');
|
|
545
|
+
}).catch(() => showToast('Copy failed — clipboard not available', 'error'));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function verifyEntry(slug, btn) {
|
|
549
|
+
btn.disabled = true;
|
|
550
|
+
btn.textContent = 'Verifying…';
|
|
551
|
+
try {
|
|
552
|
+
const data = await apiPost(`/api/entries/${slug}/verify`);
|
|
553
|
+
showToast(`Marked as verified (${data.lastVerified}). ${data.note}`, 'info');
|
|
554
|
+
btn.textContent = 'Verified ✓';
|
|
555
|
+
} catch (e) {
|
|
556
|
+
showToast(`Failed: ${e.message}`, 'error');
|
|
557
|
+
btn.disabled = false;
|
|
558
|
+
btn.textContent = 'Mark Verified';
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ─── Report View ───────────────────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
async function loadReportView() {
|
|
565
|
+
if (state.viewsLoaded.has('report')) return;
|
|
566
|
+
state.viewsLoaded.add('report');
|
|
567
|
+
|
|
568
|
+
const container = document.getElementById('report-content');
|
|
569
|
+
if (!container) return;
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
const { report } = await apiFetch('/api/refresh-report');
|
|
573
|
+
container.innerHTML = renderReport(report);
|
|
574
|
+
|
|
575
|
+
container.querySelectorAll('.verify-row-btn').forEach(btn => {
|
|
576
|
+
btn.addEventListener('click', () => verifyEntryRow(btn.dataset.slug, btn));
|
|
577
|
+
});
|
|
578
|
+
} catch (e) {
|
|
579
|
+
container.innerHTML = `<div class="empty-state">
|
|
580
|
+
<span class="empty-state-icon">⚠</span>
|
|
581
|
+
<div class="empty-state-title">Failed to load report</div>
|
|
582
|
+
<div class="empty-state-subtitle">${escapeHtml(e.message)}</div>
|
|
583
|
+
</div>`;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function renderReport(report) {
|
|
588
|
+
const order = ['critical', 'stale', 'aging', 'fresh'];
|
|
589
|
+
let html = '';
|
|
590
|
+
|
|
591
|
+
for (const level of order) {
|
|
592
|
+
const group = report[level];
|
|
593
|
+
if (!group || group.length === 0) continue;
|
|
594
|
+
|
|
595
|
+
html += `<div class="staleness-group">
|
|
596
|
+
<div class="staleness-group-header ${level}">
|
|
597
|
+
${getStalenessEmoji(level)} ${capitalize(level)}
|
|
598
|
+
<span class="staleness-group-count">${group.length} entries</span>
|
|
599
|
+
</div>`;
|
|
600
|
+
|
|
601
|
+
for (const item of group) {
|
|
602
|
+
html += `<div class="entry-row">
|
|
603
|
+
<span class="entry-row-name">${escapeHtml(item.name)}</span>
|
|
604
|
+
<span class="entry-row-slug">${escapeHtml(item.slug)}</span>
|
|
605
|
+
<span class="entry-row-days">${item.daysSinceVerified}d ago</span>
|
|
606
|
+
<div class="entry-row-actions">
|
|
607
|
+
<span class="badge ${getStalenessBadge(level)}">${getStalenessLabel(level)}</span>
|
|
608
|
+
<button class="btn btn-sm btn-ghost verify-row-btn" data-slug="${item.slug}">Verify</button>
|
|
609
|
+
</div>
|
|
610
|
+
</div>`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
html += '</div>';
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!html) {
|
|
617
|
+
html = `<div class="empty-state">
|
|
618
|
+
<span class="empty-state-icon">✅</span>
|
|
619
|
+
<div class="empty-state-title">All entries are fresh!</div>
|
|
620
|
+
<div class="empty-state-subtitle">Nothing needs verification right now.</div>
|
|
621
|
+
</div>`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return html;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function verifyEntryRow(slug, btn) {
|
|
628
|
+
btn.disabled = true;
|
|
629
|
+
btn.textContent = '…';
|
|
630
|
+
try {
|
|
631
|
+
await apiPost(`/api/entries/${slug}/verify`);
|
|
632
|
+
showToast(`${slug} marked as verified`, 'success');
|
|
633
|
+
btn.textContent = 'Done ✓';
|
|
634
|
+
const row = btn.closest('.entry-row');
|
|
635
|
+
if (row) {
|
|
636
|
+
row.style.opacity = '0.4';
|
|
637
|
+
row.style.transition = 'opacity 0.3s ease';
|
|
638
|
+
}
|
|
639
|
+
} catch (e) {
|
|
640
|
+
showToast(`Failed: ${e.message}`, 'error');
|
|
641
|
+
btn.disabled = false;
|
|
642
|
+
btn.textContent = 'Verify';
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ─── Lint View ─────────────────────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
async function loadLintView() {
|
|
649
|
+
if (state.viewsLoaded.has('lint')) return;
|
|
650
|
+
state.viewsLoaded.add('lint');
|
|
651
|
+
|
|
652
|
+
const container = document.getElementById('lint-content');
|
|
653
|
+
if (!container) return;
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
const data = await apiFetch('/api/lint');
|
|
657
|
+
container.innerHTML = renderLint(data);
|
|
658
|
+
} catch (e) {
|
|
659
|
+
container.innerHTML = `<div class="empty-state">
|
|
660
|
+
<span class="empty-state-icon">⚠</span>
|
|
661
|
+
<div class="empty-state-title">Failed to load lint results</div>
|
|
662
|
+
<div class="empty-state-subtitle">${escapeHtml(e.message)}</div>
|
|
663
|
+
</div>`;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function renderLint(data) {
|
|
668
|
+
const failing = data.results.filter(r => !r.passed);
|
|
669
|
+
const passing = data.results.filter(r => r.passed);
|
|
670
|
+
|
|
671
|
+
let html = `<div class="lint-summary-bar">
|
|
672
|
+
<div class="lint-summary-item"><span style="color:var(--color-success)">✓</span> ${data.passing} passing</div>
|
|
673
|
+
<div class="lint-summary-item"><span style="color:var(--color-danger)">✗</span> ${data.failing} failing</div>
|
|
674
|
+
<div class="lint-summary-item">Total: ${data.total}</div>
|
|
675
|
+
</div>`;
|
|
676
|
+
|
|
677
|
+
if (failing.length > 0) {
|
|
678
|
+
html += `<h3 style="font-size:14px;font-weight:700;margin-bottom:10px;color:var(--color-danger)">Failing (${failing.length})</h3>`;
|
|
679
|
+
for (const r of failing) {
|
|
680
|
+
html += renderLintEntry(r);
|
|
681
|
+
}
|
|
682
|
+
html += '<div style="height:20px"></div>';
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (passing.length > 0) {
|
|
686
|
+
html += `<h3 style="font-size:14px;font-weight:700;margin-bottom:10px;color:var(--color-success)">Passing (${passing.length})</h3>`;
|
|
687
|
+
for (const r of passing) {
|
|
688
|
+
html += renderLintEntry(r);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return html;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function renderLintEntry(result) {
|
|
696
|
+
const icon = result.passed ? '✓' : '✗';
|
|
697
|
+
const color = result.passed ? 'var(--color-success)' : 'var(--color-danger)';
|
|
698
|
+
|
|
699
|
+
const issuesHtml = result.issues.length > 0
|
|
700
|
+
? `<div class="lint-issues-list">${result.issues.map(i => {
|
|
701
|
+
const sev = i.severity || 'warning';
|
|
702
|
+
const ico = sev === 'error' ? '✗' : sev === 'warning' ? '⚠' : 'ℹ';
|
|
703
|
+
return `<div class="lint-issue ${sev}">
|
|
704
|
+
<span class="lint-issue-icon">${ico}</span>
|
|
705
|
+
<span>${escapeHtml(i.message)}</span>
|
|
706
|
+
</div>`;
|
|
707
|
+
}).join('')}</div>`
|
|
708
|
+
: '';
|
|
709
|
+
|
|
710
|
+
return `<div class="lint-entry">
|
|
711
|
+
<div class="lint-entry-header">
|
|
712
|
+
<span style="color:${color};font-size:15px;font-weight:700">${icon}</span>
|
|
713
|
+
<span class="lint-entry-name">${escapeHtml(result.name || result.slug)}</span>
|
|
714
|
+
<span style="font-family:var(--font-mono);font-size:12px;color:var(--color-text-muted)">${escapeHtml(result.slug)}</span>
|
|
715
|
+
${result.issues.length > 0 ? `<span class="lint-entry-issues-count">${result.issues.length} issue${result.issues.length !== 1 ? 's' : ''}</span>` : ''}
|
|
716
|
+
</div>
|
|
717
|
+
${issuesHtml}
|
|
718
|
+
</div>`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ─── Search Test View ──────────────────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
async function runSearchTest(query) {
|
|
724
|
+
const container = document.getElementById('test-results');
|
|
725
|
+
if (!container || !query.trim()) return;
|
|
726
|
+
|
|
727
|
+
container.innerHTML = '<div class="loading-state">Running search…</div>';
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
const data = await apiFetch(`/api/search?q=${encodeURIComponent(query.trim())}`);
|
|
731
|
+
container.innerHTML = renderSearchResults(data);
|
|
732
|
+
} catch (e) {
|
|
733
|
+
container.innerHTML = `<div class="empty-state">
|
|
734
|
+
<span class="empty-state-icon">⚠</span>
|
|
735
|
+
<div class="empty-state-title">Search failed</div>
|
|
736
|
+
<div class="empty-state-subtitle">${escapeHtml(e.message)}</div>
|
|
737
|
+
</div>`;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function renderSearchResults(data) {
|
|
742
|
+
if (data.count === 0) {
|
|
743
|
+
return `<div class="empty-state">
|
|
744
|
+
<span class="empty-state-icon">🔍</span>
|
|
745
|
+
<div class="empty-state-title">No results for "${escapeHtml(data.query)}"</div>
|
|
746
|
+
</div>`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return data.results.map((r, i) => renderTestResultCard(r, i + 1)).join('');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function renderTestResultCard(r, rank) {
|
|
753
|
+
const breakdown = r.scoreBreakdown || {};
|
|
754
|
+
const factors = Object.entries(breakdown);
|
|
755
|
+
|
|
756
|
+
const scoreClass = scoreBarClass(r.score);
|
|
757
|
+
const confClass = scoreBarClass(r.confidence / 100);
|
|
758
|
+
|
|
759
|
+
const breakdownRows = factors.map(([name, value]) => {
|
|
760
|
+
const numVal = typeof value === 'number' ? value : 0;
|
|
761
|
+
const pct = Math.round(numVal * 100);
|
|
762
|
+
return `<div class="td factor-name">${escapeHtml(name)}</div>
|
|
763
|
+
<div class="td factor-value">${numVal.toFixed(3)}</div>
|
|
764
|
+
<div class="mini-bar-track"><div class="mini-bar-fill" style="width:${pct}%"></div></div>`;
|
|
765
|
+
}).join('');
|
|
766
|
+
|
|
767
|
+
const breakdownHtml = factors.length > 0 ? `
|
|
768
|
+
<div class="score-breakdown-table" style="margin-top:12px">
|
|
769
|
+
<div class="th">Factor</div>
|
|
770
|
+
<div class="th" style="text-align:right">Value</div>
|
|
771
|
+
<div class="th">Bar</div>
|
|
772
|
+
${breakdownRows}
|
|
773
|
+
</div>` : '';
|
|
774
|
+
|
|
775
|
+
const bestFor = Array.isArray(r.bestFor) ? r.bestFor.slice(0, 2).join(', ') : (r.bestFor || '');
|
|
776
|
+
|
|
777
|
+
return `<div class="test-result-card">
|
|
778
|
+
<div class="test-result-header">
|
|
779
|
+
<span class="test-result-rank">${rank}</span>
|
|
780
|
+
<span class="test-result-name">${escapeHtml(r.name)}</span>
|
|
781
|
+
<span class="category-badge" style="background:${getCategoryColor(r.category)}">${r.category}</span>
|
|
782
|
+
<span class="test-result-confidence">Confidence: ${r.confidence}%</span>
|
|
783
|
+
</div>
|
|
784
|
+
<div class="score-bar-row">
|
|
785
|
+
<span class="score-bar-label">Score</span>
|
|
786
|
+
<div class="score-bar-track">
|
|
787
|
+
<div class="score-bar-fill ${scoreClass}" style="width:${Math.round(r.score * 100)}%"></div>
|
|
788
|
+
</div>
|
|
789
|
+
<span class="score-bar-value">${r.score.toFixed(3)}</span>
|
|
790
|
+
</div>
|
|
791
|
+
<div class="score-bar-row">
|
|
792
|
+
<span class="score-bar-label">Confidence</span>
|
|
793
|
+
<div class="score-bar-track">
|
|
794
|
+
<div class="score-bar-fill ${confClass}" style="width:${r.confidence}%"></div>
|
|
795
|
+
</div>
|
|
796
|
+
<span class="score-bar-value">${r.confidence}%</span>
|
|
797
|
+
</div>
|
|
798
|
+
${bestFor ? `<div style="font-size:12.5px;color:var(--color-text-muted);margin-bottom:4px"><strong style="color:var(--color-text)">Best for:</strong> ${escapeHtml(bestFor)}</div>` : ''}
|
|
799
|
+
<div style="font-size:12.5px;color:var(--color-text-muted)">${escapeHtml(r.description)}</div>
|
|
800
|
+
${breakdownHtml}
|
|
801
|
+
</div>`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ─── Navigation & View Switching ───────────────────────────────────────────────
|
|
805
|
+
|
|
806
|
+
function switchView(viewId) {
|
|
807
|
+
state.activeView = viewId;
|
|
808
|
+
|
|
809
|
+
document.querySelectorAll('.view').forEach(el => el.classList.remove('active'));
|
|
810
|
+
document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
|
811
|
+
|
|
812
|
+
const view = document.getElementById(`view-${viewId}`);
|
|
813
|
+
if (view) view.classList.add('active');
|
|
814
|
+
|
|
815
|
+
const navItem = document.querySelector(`.nav-item[data-view="${viewId}"]`);
|
|
816
|
+
if (navItem) navItem.classList.add('active');
|
|
817
|
+
|
|
818
|
+
if (viewId === 'report') loadReportView();
|
|
819
|
+
if (viewId === 'lint') loadLintView();
|
|
820
|
+
|
|
821
|
+
updateHash();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ─── URL Hash ──────────────────────────────────────────────────────────────────
|
|
825
|
+
|
|
826
|
+
function updateHash() {
|
|
827
|
+
const parts = [];
|
|
828
|
+
if (state.activeView !== 'dashboard') parts.push(`view=${state.activeView}`);
|
|
829
|
+
if (state.activeCategory !== 'all') parts.push(`category=${state.activeCategory}`);
|
|
830
|
+
if (state.searchQuery) parts.push(`q=${encodeURIComponent(state.searchQuery)}`);
|
|
831
|
+
const hash = parts.join('&');
|
|
832
|
+
history.replaceState(null, '', hash ? `#${hash}` : location.pathname + location.search);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function parseHash() {
|
|
836
|
+
const hash = location.hash.slice(1);
|
|
837
|
+
if (!hash) return;
|
|
838
|
+
const params = new URLSearchParams(hash);
|
|
839
|
+
const view = params.get('view');
|
|
840
|
+
const category = params.get('category');
|
|
841
|
+
const q = params.get('q');
|
|
842
|
+
|
|
843
|
+
if (view) state.activeView = view;
|
|
844
|
+
if (category) state.activeCategory = category;
|
|
845
|
+
if (q) state.searchQuery = decodeURIComponent(q);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ─── Utility ───────────────────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
function escapeHtml(str) {
|
|
851
|
+
if (typeof str !== 'string') return String(str ?? '');
|
|
852
|
+
return str
|
|
853
|
+
.replace(/&/g, '&')
|
|
854
|
+
.replace(/</g, '<')
|
|
855
|
+
.replace(/>/g, '>')
|
|
856
|
+
.replace(/"/g, '"')
|
|
857
|
+
.replace(/'/g, ''');
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function capitalize(str) {
|
|
861
|
+
if (!str) return '';
|
|
862
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ─── Initialization ────────────────────────────────────────────────────────────
|
|
866
|
+
|
|
867
|
+
async function loadStats() {
|
|
868
|
+
try {
|
|
869
|
+
state.stats = await apiFetch('/api/stats');
|
|
870
|
+
renderStats();
|
|
871
|
+
} catch (e) {
|
|
872
|
+
console.error('Failed to load stats:', e);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function loadCategories() {
|
|
877
|
+
try {
|
|
878
|
+
state.categories = await apiFetch('/api/categories');
|
|
879
|
+
renderCategoryList();
|
|
880
|
+
} catch (e) {
|
|
881
|
+
console.error('Failed to load categories:', e);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function loadEntries() {
|
|
886
|
+
const grid = document.getElementById('entries-grid');
|
|
887
|
+
try {
|
|
888
|
+
const sortParts = state.sortValue.split('-');
|
|
889
|
+
const sort = sortParts[0];
|
|
890
|
+
const order = sortParts[1] || 'asc';
|
|
891
|
+
state.entries = await apiFetch(`/api/entries?sort=${sort}&order=${order}`);
|
|
892
|
+
filterAndRender();
|
|
893
|
+
} catch (e) {
|
|
894
|
+
if (grid) grid.innerHTML = `<div class="empty-state">
|
|
895
|
+
<span class="empty-state-icon">⚠</span>
|
|
896
|
+
<div class="empty-state-title">Failed to load entries</div>
|
|
897
|
+
<div class="empty-state-subtitle">${escapeHtml(e.message)}</div>
|
|
898
|
+
</div>`;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function setupEventListeners() {
|
|
903
|
+
// Navigation
|
|
904
|
+
document.querySelectorAll('.nav-item[data-view]').forEach(el => {
|
|
905
|
+
el.addEventListener('click', e => {
|
|
906
|
+
e.preventDefault();
|
|
907
|
+
switchView(el.dataset.view);
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// Search input
|
|
912
|
+
const searchInput = document.getElementById('search-input');
|
|
913
|
+
if (searchInput) {
|
|
914
|
+
if (state.searchQuery) {
|
|
915
|
+
searchInput.value = state.searchQuery;
|
|
916
|
+
}
|
|
917
|
+
searchInput.addEventListener('input', e => onSearchInput(e.target.value));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Search clear button
|
|
921
|
+
const clearBtn = document.getElementById('search-clear');
|
|
922
|
+
if (clearBtn) {
|
|
923
|
+
if (state.searchQuery) clearBtn.hidden = false;
|
|
924
|
+
clearBtn.addEventListener('click', () => {
|
|
925
|
+
if (searchInput) searchInput.value = '';
|
|
926
|
+
state.searchQuery = '';
|
|
927
|
+
clearBtn.hidden = true;
|
|
928
|
+
filterAndRender();
|
|
929
|
+
updateHash();
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Sort select
|
|
934
|
+
const sortSelect = document.getElementById('sort-select');
|
|
935
|
+
if (sortSelect) {
|
|
936
|
+
sortSelect.value = state.sortValue;
|
|
937
|
+
sortSelect.addEventListener('change', e => {
|
|
938
|
+
state.sortValue = e.target.value;
|
|
939
|
+
if (!state.searchQuery) {
|
|
940
|
+
state.filtered = sortEntries(state.filtered, state.sortValue);
|
|
941
|
+
renderGrid(state.filtered);
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Modal close button
|
|
947
|
+
const modalClose = document.getElementById('modal-close');
|
|
948
|
+
if (modalClose) modalClose.addEventListener('click', closeModal);
|
|
949
|
+
|
|
950
|
+
// Modal overlay click-outside
|
|
951
|
+
const modalOverlay = document.getElementById('detail-modal');
|
|
952
|
+
if (modalOverlay) {
|
|
953
|
+
modalOverlay.addEventListener('click', e => {
|
|
954
|
+
if (e.target === modalOverlay) closeModal();
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Escape key
|
|
959
|
+
document.addEventListener('keydown', e => {
|
|
960
|
+
if (e.key === 'Escape') closeModal();
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// Search test view
|
|
964
|
+
const testBtn = document.getElementById('test-query-btn');
|
|
965
|
+
const testInput = document.getElementById('test-query-input');
|
|
966
|
+
if (testBtn && testInput) {
|
|
967
|
+
testBtn.addEventListener('click', () => runSearchTest(testInput.value));
|
|
968
|
+
testInput.addEventListener('keydown', e => {
|
|
969
|
+
if (e.key === 'Enter') runSearchTest(testInput.value);
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Preset buttons
|
|
974
|
+
document.querySelectorAll('.preset-btn').forEach(btn => {
|
|
975
|
+
btn.addEventListener('click', () => {
|
|
976
|
+
const q = btn.dataset.q;
|
|
977
|
+
if (testInput) testInput.value = q;
|
|
978
|
+
runSearchTest(q);
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function init() {
|
|
984
|
+
parseHash();
|
|
985
|
+
|
|
986
|
+
setupEventListeners();
|
|
987
|
+
switchView(state.activeView);
|
|
988
|
+
|
|
989
|
+
await Promise.all([
|
|
990
|
+
loadStats(),
|
|
991
|
+
loadCategories(),
|
|
992
|
+
loadEntries(),
|
|
993
|
+
]);
|
|
994
|
+
|
|
995
|
+
if (state.searchQuery) {
|
|
996
|
+
const searchInput = document.getElementById('search-input');
|
|
997
|
+
if (searchInput) searchInput.value = state.searchQuery;
|
|
998
|
+
const clearBtn = document.getElementById('search-clear');
|
|
999
|
+
if (clearBtn) clearBtn.hidden = false;
|
|
1000
|
+
performSearch(state.searchQuery);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
document.addEventListener('DOMContentLoaded', init);
|