agentlytics 0.1.10 → 0.1.12
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/cache.js +6 -2
- package/package.json +1 -1
- package/server.js +15 -3
- package/share-image.js +289 -75
- package/ui/src/components/ShareModal.jsx +305 -0
- package/ui/src/lib/api.js +12 -2
- package/ui/src/pages/Dashboard.jsx +7 -54
package/cache.js
CHANGED
|
@@ -431,14 +431,17 @@ function getCachedOverview(opts = {}) {
|
|
|
431
431
|
const hf = hiddenFolderFilter(opts);
|
|
432
432
|
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
433
433
|
if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
|
|
434
|
+
if (opts.folder) { conditions.push('folder = ?'); params.push(opts.folder); }
|
|
434
435
|
if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
|
|
435
436
|
if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
|
|
436
437
|
const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
|
|
437
438
|
const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
|
|
438
439
|
|
|
439
440
|
const totalChats = db.prepare(`SELECT COUNT(*) as cnt FROM chats${where}`).get(...params).cnt;
|
|
440
|
-
//
|
|
441
|
-
const editors =
|
|
441
|
+
// When folder-filtered, show only that project's editors; otherwise show all
|
|
442
|
+
const editors = opts.folder
|
|
443
|
+
? db.prepare(`SELECT source, COUNT(*) as count FROM chats${where} GROUP BY source ORDER BY count DESC`).all(...params)
|
|
444
|
+
: db.prepare('SELECT source, COUNT(*) as count FROM chats GROUP BY source ORDER BY count DESC').all();
|
|
442
445
|
|
|
443
446
|
// By mode
|
|
444
447
|
const modes = db.prepare(`SELECT mode, COUNT(*) as count FROM chats WHERE mode IS NOT NULL${whereAnd} GROUP BY mode`).all(...params);
|
|
@@ -851,6 +854,7 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
851
854
|
const hf = hiddenFolderFilter(opts);
|
|
852
855
|
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
853
856
|
if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
|
|
857
|
+
if (opts.folder) { conditions.push('folder = ?'); params.push(opts.folder); }
|
|
854
858
|
if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
|
|
855
859
|
if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
|
|
856
860
|
const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlytics",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/server.js
CHANGED
|
@@ -264,9 +264,21 @@ app.get('/api/schema', (req, res) => {
|
|
|
264
264
|
|
|
265
265
|
app.get('/api/share-image', (req, res) => {
|
|
266
266
|
try {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
const
|
|
267
|
+
const filterOpts = { hiddenFolders: getHiddenFolders() };
|
|
268
|
+
if (req.query.folder) filterOpts.folder = req.query.folder;
|
|
269
|
+
const overview = cache.getCachedOverview(filterOpts);
|
|
270
|
+
const stats = cache.getCachedDashboardStats(filterOpts);
|
|
271
|
+
const costs = cache.getCostAnalytics(filterOpts);
|
|
272
|
+
const opts = {};
|
|
273
|
+
if (req.query.showEditors !== undefined) opts.showEditors = req.query.showEditors !== 'false';
|
|
274
|
+
if (req.query.showModels !== undefined) opts.showModels = req.query.showModels !== 'false';
|
|
275
|
+
if (req.query.showCosts !== undefined) opts.showCosts = req.query.showCosts !== 'false';
|
|
276
|
+
if (req.query.showTokens !== undefined) opts.showTokens = req.query.showTokens !== 'false';
|
|
277
|
+
if (req.query.showHours !== undefined) opts.showHours = req.query.showHours !== 'false';
|
|
278
|
+
if (req.query.username) opts.username = req.query.username;
|
|
279
|
+
if (req.query.theme) opts.theme = req.query.theme;
|
|
280
|
+
if (req.query.folder) opts.folder = req.query.folder;
|
|
281
|
+
const svg = generateShareSvg(overview, stats, costs, opts);
|
|
270
282
|
res.setHeader('Content-Type', 'image/svg+xml');
|
|
271
283
|
res.send(svg);
|
|
272
284
|
} catch (err) {
|
package/share-image.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generates a shareable SVG stats card from cached data.
|
|
3
|
+
* Accepts an `opts` object to toggle sections on/off.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
function fmt(n) {
|
|
@@ -9,6 +10,14 @@ function fmt(n) {
|
|
|
9
10
|
return n.toLocaleString();
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
function fmtCost(n) {
|
|
14
|
+
if (n == null || n === 0) return '$0';
|
|
15
|
+
if (n < 0.01) return '<$0.01';
|
|
16
|
+
if (n >= 1000) return '$' + (n / 1000).toFixed(1) + 'K';
|
|
17
|
+
if (n >= 100) return '$' + Math.round(n);
|
|
18
|
+
return '$' + n.toFixed(2);
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
const EDITOR_COLORS = {
|
|
13
22
|
'cursor': '#f59e0b',
|
|
14
23
|
'windsurf': '#06b6d4',
|
|
@@ -20,9 +29,11 @@ const EDITOR_COLORS = {
|
|
|
20
29
|
'vscode-insiders': '#60a5fa',
|
|
21
30
|
'zed': '#10b981',
|
|
22
31
|
'opencode': '#ec4899',
|
|
32
|
+
'codex': '#0f766e',
|
|
23
33
|
'gemini-cli': '#4285f4',
|
|
24
34
|
'copilot-cli': '#8957e5',
|
|
25
35
|
'cursor-agent': '#f59e0b',
|
|
36
|
+
'commandcode': '#e11d48',
|
|
26
37
|
};
|
|
27
38
|
|
|
28
39
|
const EDITOR_LABELS = {
|
|
@@ -36,107 +47,310 @@ const EDITOR_LABELS = {
|
|
|
36
47
|
'vscode-insiders': 'VS Code Ins.',
|
|
37
48
|
'zed': 'Zed',
|
|
38
49
|
'opencode': 'OpenCode',
|
|
50
|
+
'codex': 'Codex',
|
|
39
51
|
'gemini-cli': 'Gemini CLI',
|
|
40
52
|
'copilot-cli': 'Copilot CLI',
|
|
41
53
|
'cursor-agent': 'Cursor Agent',
|
|
54
|
+
'commandcode': 'Cmd Code',
|
|
42
55
|
};
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
/**
|
|
58
|
+
* @param {object} overview — from getCachedOverview()
|
|
59
|
+
* @param {object} stats — from getCachedDashboardStats()
|
|
60
|
+
* @param {object} costs — from getCostBreakdown()
|
|
61
|
+
* @param {object} [opts] — toggle sections
|
|
62
|
+
* @param {boolean} [opts.showEditors=true]
|
|
63
|
+
* @param {boolean} [opts.showModels=true]
|
|
64
|
+
* @param {boolean} [opts.showCosts=true]
|
|
65
|
+
* @param {boolean} [opts.showTokens=true]
|
|
66
|
+
* @param {boolean} [opts.showHours=true]
|
|
67
|
+
* @param {boolean} [opts.showProjects=true]
|
|
68
|
+
* @param {string} [opts.username]
|
|
69
|
+
* @param {string} [opts.theme='dark'] — 'dark' or 'light'
|
|
70
|
+
*/
|
|
71
|
+
const THEMES = {
|
|
72
|
+
dark: {
|
|
73
|
+
bg: '#09090f', bg2: '#111118', card: '#111', border: '#1e1e2a',
|
|
74
|
+
text: '#fff', text2: '#888', text3: '#666', text4: '#555', text5: '#444',
|
|
75
|
+
titleText: '#555',
|
|
76
|
+
hourHigh: '#818cf8', hourMed: '#6366f1', hourLow: '#4f46e5', hourMin: '#1e1b4b',
|
|
77
|
+
},
|
|
78
|
+
light: {
|
|
79
|
+
bg: '#f8f8fa', bg2: '#eeeef2', card: '#e8e8ee', border: '#d0d0d8',
|
|
80
|
+
text: '#111', text2: '#555', text3: '#888', text4: '#999', text5: '#aaa',
|
|
81
|
+
titleText: '#888',
|
|
82
|
+
hourHigh: '#6366f1', hourMed: '#818cf8', hourLow: '#a5b4fc', hourMin: '#e0e7ff',
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function generateShareSvg(overview, stats, costs, opts = {}) {
|
|
87
|
+
const show = {
|
|
88
|
+
editors: opts.showEditors !== false,
|
|
89
|
+
models: opts.showModels !== false,
|
|
90
|
+
costs: opts.showCosts !== false,
|
|
91
|
+
tokens: opts.showTokens !== false,
|
|
92
|
+
hours: opts.showHours !== false,
|
|
93
|
+
};
|
|
94
|
+
const username = opts.username || '';
|
|
95
|
+
const t = THEMES[opts.theme] || THEMES.dark;
|
|
96
|
+
const projectFolder = opts.folder || '';
|
|
97
|
+
const projectName = projectFolder ? projectFolder.split(/[/\\]/).pop() : '';
|
|
98
|
+
|
|
99
|
+
const W = 1200;
|
|
100
|
+
const H_FIXED = 675;
|
|
101
|
+
const F = "\"'Menlo','Monaco','Cascadia Code','Courier New',monospace\"";
|
|
47
102
|
const editors = overview.editors || [];
|
|
48
103
|
const tk = stats.tokens || {};
|
|
49
104
|
const streaks = stats.streaks || {};
|
|
50
105
|
const topModels = (stats.topModels || []).slice(0, 5);
|
|
106
|
+
const costData = costs || {};
|
|
107
|
+
const totalCost = costData.totalCost || 0;
|
|
108
|
+
const costByEditor = (costData.byEditor || []).slice(0, 6);
|
|
109
|
+
const totalTokens = (tk.input || 0) + (tk.output || 0);
|
|
110
|
+
const now = new Date();
|
|
111
|
+
const dateStr = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
112
|
+
|
|
113
|
+
// ── Compute layout ──
|
|
114
|
+
let y = 0;
|
|
115
|
+
const pad = 28;
|
|
116
|
+
|
|
117
|
+
// Title bar
|
|
118
|
+
y += 40;
|
|
119
|
+
// Header area (prompt + branding)
|
|
120
|
+
y += 54;
|
|
121
|
+
// KPI row
|
|
122
|
+
y += 80;
|
|
123
|
+
// Sections
|
|
124
|
+
const midSections = [];
|
|
125
|
+
if (show.editors) midSections.push('editors');
|
|
126
|
+
if (show.costs && totalCost > 0) midSections.push('costs');
|
|
127
|
+
if (show.hours) midSections.push('hours');
|
|
128
|
+
if (show.models && topModels.length > 0) midSections.push('models');
|
|
129
|
+
|
|
130
|
+
// Layout: 2 columns, each section ~row
|
|
131
|
+
const leftSections = [];
|
|
132
|
+
const rightSections = [];
|
|
133
|
+
midSections.forEach((s, i) => {
|
|
134
|
+
if (i % 2 === 0) leftSections.push(s);
|
|
135
|
+
else rightSections.push(s);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function sectionHeight(name) {
|
|
139
|
+
if (name === 'editors') return Math.max(editors.slice(0, 8).length * 26 + 34, 70);
|
|
140
|
+
if (name === 'costs') return Math.max(costByEditor.length * 26 + 34, 70);
|
|
141
|
+
if (name === 'hours') return 110;
|
|
142
|
+
if (name === 'models') return topModels.length * 22 + 34;
|
|
143
|
+
return 70;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Mid-section pairs: compute max height of each row
|
|
147
|
+
const rowCount = Math.max(leftSections.length, rightSections.length);
|
|
148
|
+
let midHeight = 0;
|
|
149
|
+
for (let i = 0; i < rowCount; i++) {
|
|
150
|
+
const lh = leftSections[i] ? sectionHeight(leftSections[i]) : 0;
|
|
151
|
+
const rh = rightSections[i] ? sectionHeight(rightSections[i]) : 0;
|
|
152
|
+
midHeight += Math.max(lh, rh) + 8;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Token footer
|
|
156
|
+
const tokenFooterH = show.tokens ? 44 : 0;
|
|
157
|
+
// Footer
|
|
158
|
+
const footerH = 36;
|
|
159
|
+
|
|
160
|
+
const naturalH = y + midHeight + tokenFooterH + footerH + 20;
|
|
161
|
+
// Stretch: if content is shorter than 675, distribute extra space into sections
|
|
162
|
+
const extraSpace = Math.max(0, H_FIXED - naturalH);
|
|
163
|
+
const extraPerRow = rowCount > 0 ? extraSpace / rowCount : 0;
|
|
51
164
|
|
|
52
|
-
//
|
|
165
|
+
// ── KPI cards ──
|
|
166
|
+
const kpiY = 94;
|
|
167
|
+
const kpiCards = [];
|
|
168
|
+
const kpiItems = [
|
|
169
|
+
{ label: 'sessions', value: fmt(overview.totalChats) },
|
|
170
|
+
{ label: 'tokens', value: fmt(totalTokens) },
|
|
171
|
+
{ label: 'active days', value: String(streaks.totalDays || 0) },
|
|
172
|
+
{ label: 'streak', value: `${streaks.current || 0}d` },
|
|
173
|
+
];
|
|
174
|
+
if (show.costs && totalCost > 0) {
|
|
175
|
+
kpiItems.push({ label: 'est. cost', value: fmtCost(totalCost) });
|
|
176
|
+
}
|
|
177
|
+
const kpiW = (W - pad * 2 - (kpiItems.length - 1) * 8) / kpiItems.length;
|
|
178
|
+
kpiItems.forEach((item, i) => {
|
|
179
|
+
const x = pad + i * (kpiW + 8);
|
|
180
|
+
kpiCards.push(`
|
|
181
|
+
<rect x="${x}" y="${kpiY}" width="${kpiW}" height="64" rx="6" fill="${t.card}"/>
|
|
182
|
+
<text x="${x + 14}" y="${kpiY + 22}" fill="${t.text3}" font-size="11" font-family=${F}>${esc(item.label)}</text>
|
|
183
|
+
<text x="${x + 14}" y="${kpiY + 50}" fill="${t.text}" font-size="24" font-weight="bold" font-family=${F}>${esc(item.value)}</text>
|
|
184
|
+
`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── Editor bar chart ──
|
|
188
|
+
const colW = (W - pad * 2 - 20) / 2;
|
|
189
|
+
const maxBarW = colW - 120 - 60;
|
|
53
190
|
const maxEditorCount = Math.max(...editors.map(e => e.count), 1);
|
|
54
|
-
const
|
|
55
|
-
const barW = Math.max((e.count / maxEditorCount) *
|
|
191
|
+
const editorBarsArr = editors.slice(0, 8).map((e, i) => {
|
|
192
|
+
const barW = Math.max((e.count / maxEditorCount) * maxBarW, 4);
|
|
56
193
|
const color = EDITOR_COLORS[e.id] || '#6b7280';
|
|
57
|
-
const label = (EDITOR_LABELS[e.id] || e.id)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
194
|
+
const label = (EDITOR_LABELS[e.id] || e.id);
|
|
195
|
+
return { label, barW, color, count: e.count };
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── Cost bar chart ──
|
|
199
|
+
const maxCostVal = costByEditor.length > 0 ? Math.max(...costByEditor.map(c => c.cost), 0.01) : 1;
|
|
200
|
+
const costBarsArr = costByEditor.map(c => {
|
|
201
|
+
const barW = Math.max((c.cost / maxCostVal) * maxBarW, 4);
|
|
202
|
+
const color = EDITOR_COLORS[c.editor] || '#6b7280';
|
|
203
|
+
const label = EDITOR_LABELS[c.editor] || c.editor;
|
|
204
|
+
return { label, barW, color, value: fmtCost(c.cost) };
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ── Hourly sparkline ──
|
|
67
208
|
const hourly = stats.hourly || new Array(24).fill(0);
|
|
68
|
-
const
|
|
69
|
-
const sparkW = 180, sparkH = 40;
|
|
70
|
-
const sparkPoints = hourly.map((v, i) => {
|
|
71
|
-
const x = 590 + (i / 23) * sparkW;
|
|
72
|
-
const y = 180 + sparkH - (v / maxH) * sparkH;
|
|
73
|
-
return `${x},${y}`;
|
|
74
|
-
}).join(' ');
|
|
75
|
-
|
|
76
|
-
// Top models list
|
|
77
|
-
const modelsList = topModels.map((m, i) => {
|
|
78
|
-
const y = 274 + i * 16;
|
|
79
|
-
const name = m.name.length > 24 ? m.name.substring(0, 24) : m.name;
|
|
80
|
-
return `<text x="590" y="${y}" fill="#888" font-size="9" font-family="${F}">${esc(name)} <tspan fill="#555">${m.count}</tspan></text>`;
|
|
81
|
-
}).join('');
|
|
82
|
-
|
|
83
|
-
const dateStr = new Date().toISOString().split('T')[0];
|
|
84
|
-
|
|
85
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
|
86
|
-
<!-- Background -->
|
|
87
|
-
<rect width="${W}" height="${H}" fill="#000"/>
|
|
88
|
-
<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" fill="none" stroke="#222" stroke-width="1"/>
|
|
209
|
+
const maxHourly = Math.max(...hourly, 1);
|
|
89
210
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
211
|
+
// ── Build sections ──
|
|
212
|
+
let curY = kpiY + 64 + 18;
|
|
213
|
+
const sectionSvgs = [];
|
|
93
214
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
215
|
+
for (let row = 0; row < rowCount; row++) {
|
|
216
|
+
const lName = leftSections[row];
|
|
217
|
+
const rName = rightSections[row];
|
|
218
|
+
const lh = lName ? sectionHeight(lName) : 0;
|
|
219
|
+
const rh = rName ? sectionHeight(rName) : 0;
|
|
220
|
+
const rowH = Math.max(lh, rh) + Math.round(extraPerRow);
|
|
97
221
|
|
|
98
|
-
|
|
99
|
-
|
|
222
|
+
if (lName) sectionSvgs.push(renderSection(lName, pad, curY, colW, rowH));
|
|
223
|
+
if (rName) sectionSvgs.push(renderSection(rName, pad + colW + 20, curY, colW, rowH));
|
|
224
|
+
|
|
225
|
+
curY += rowH + 10;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderSection(name, sx, sy, sw, sh) {
|
|
229
|
+
let out = '';
|
|
230
|
+
if (name === 'editors') {
|
|
231
|
+
out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># editors</text>`;
|
|
232
|
+
editorBarsArr.forEach((e, i) => {
|
|
233
|
+
const by = sy + 26 + i * 26;
|
|
234
|
+
out += `<text x="${sx}" y="${by + 14}" fill="${t.text2}" font-size="12" font-family=${F}>${esc(e.label)}</text>`;
|
|
235
|
+
out += `<rect x="${sx + 120}" y="${by + 1}" width="${e.barW}" height="16" rx="3" fill="${e.color}" opacity="0.85"/>`;
|
|
236
|
+
out += `<text x="${sx + 128 + e.barW}" y="${by + 14}" fill="${t.text3}" font-size="11" font-family=${F}>${e.count}</text>`;
|
|
237
|
+
});
|
|
238
|
+
} else if (name === 'costs') {
|
|
239
|
+
out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># est. costs by editor</text>`;
|
|
240
|
+
costBarsArr.forEach((c, i) => {
|
|
241
|
+
const by = sy + 26 + i * 26;
|
|
242
|
+
out += `<text x="${sx}" y="${by + 14}" fill="${t.text2}" font-size="12" font-family=${F}>${esc(c.label)}</text>`;
|
|
243
|
+
out += `<rect x="${sx + 120}" y="${by + 1}" width="${c.barW}" height="16" rx="3" fill="${c.color}" opacity="0.85"/>`;
|
|
244
|
+
out += `<text x="${sx + 128 + c.barW}" y="${by + 14}" fill="${t.text3}" font-size="11" font-family=${F}>${c.value}</text>`;
|
|
245
|
+
});
|
|
246
|
+
} else if (name === 'hours') {
|
|
247
|
+
out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># peak hours</text>`;
|
|
248
|
+
const sparkW = sw - 8;
|
|
249
|
+
const sparkH = 56;
|
|
250
|
+
const baseY = sy + 28;
|
|
251
|
+
const barW = sparkW / 24 - 1;
|
|
252
|
+
hourly.forEach((v, i) => {
|
|
253
|
+
const bh = Math.max((v / maxHourly) * sparkH, 1);
|
|
254
|
+
const bx = sx + i * (barW + 1);
|
|
255
|
+
const by = baseY + sparkH - bh;
|
|
256
|
+
const intensity = v / maxHourly;
|
|
257
|
+
const color = intensity > 0.75 ? t.hourHigh : intensity > 0.5 ? t.hourMed : intensity > 0.25 ? t.hourLow : t.hourMin;
|
|
258
|
+
out += `<rect x="${bx}" y="${by}" width="${barW}" height="${bh}" rx="1" fill="${color}" opacity="0.9"/>`;
|
|
259
|
+
});
|
|
260
|
+
out += `<text x="${sx}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>00</text>`;
|
|
261
|
+
out += `<text x="${sx + sparkW / 4}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>06</text>`;
|
|
262
|
+
out += `<text x="${sx + sparkW / 2}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>12</text>`;
|
|
263
|
+
out += `<text x="${sx + sparkW * 3 / 4}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>18</text>`;
|
|
264
|
+
out += `<text x="${sx + sparkW - 8}" y="${baseY + sparkH + 14}" fill="${t.text5}" font-size="9" font-family=${F}>23</text>`;
|
|
265
|
+
} else if (name === 'models') {
|
|
266
|
+
out += `<text x="${sx}" y="${sy + 14}" fill="${t.text4}" font-size="11" font-family=${F}># top models</text>`;
|
|
267
|
+
topModels.forEach((m, i) => {
|
|
268
|
+
const my = sy + 32 + i * 22;
|
|
269
|
+
const mName = m.name.length > 28 ? m.name.substring(0, 28) + '…' : m.name;
|
|
270
|
+
out += `<text x="${sx}" y="${my}" fill="${t.text2}" font-size="11" font-family=${F}>${esc(mName)}</text>`;
|
|
271
|
+
out += `<text x="${sx + sw - 4}" y="${my}" fill="${t.text4}" font-size="11" font-family=${F} text-anchor="end">${m.count}</text>`;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
100
276
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
277
|
+
// ── Token footer line ──
|
|
278
|
+
let tokenLine = '';
|
|
279
|
+
if (show.tokens) {
|
|
280
|
+
const ty = curY + 4;
|
|
281
|
+
const tkPairs = [
|
|
282
|
+
['input', fmt(tk.input)],
|
|
283
|
+
['output', fmt(tk.output)],
|
|
284
|
+
['cache read', fmt(tk.cacheRead)],
|
|
285
|
+
['cache write', fmt(tk.cacheWrite)],
|
|
286
|
+
['tools', fmt(stats.totalToolCalls || 0)],
|
|
287
|
+
['editors', String(editors.length)],
|
|
288
|
+
];
|
|
289
|
+
let tkX = pad;
|
|
290
|
+
let tkSvg = `<line x1="${pad}" y1="${ty}" x2="${W - pad}" y2="${ty}" stroke="${t.border}" stroke-width="1"/>`;
|
|
291
|
+
tkPairs.forEach(([label, val]) => {
|
|
292
|
+
tkSvg += `<text x="${tkX}" y="${ty + 20}" fill="${t.text5}" font-size="10" font-family=${F}>${label}</text>`;
|
|
293
|
+
tkSvg += `<text x="${tkX}" y="${ty + 34}" fill="${t.text2}" font-size="12" font-weight="600" font-family=${F}>${val}</text>`;
|
|
294
|
+
tkX += (W - pad * 2) / tkPairs.length;
|
|
295
|
+
});
|
|
296
|
+
tokenLine = tkSvg;
|
|
297
|
+
curY = ty + tokenFooterH;
|
|
298
|
+
}
|
|
105
299
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<text x="222" y="122" fill="#fff" font-size="22" font-weight="bold" font-family="${F}">${fmt((tk.input || 0) + (tk.output || 0))}</text>
|
|
300
|
+
// ── Final footer — pin to bottom of canvas ──
|
|
301
|
+
const footerY = H_FIXED - 28;
|
|
109
302
|
|
|
110
|
-
|
|
111
|
-
<text x="408" y="96" fill="#666" font-size="9" font-family="${F}">active_days</text>
|
|
112
|
-
<text x="408" y="122" fill="#fff" font-size="22" font-weight="bold" font-family="${F}">${streaks.totalDays || 0}</text>
|
|
303
|
+
const usernameText = username ? `<text x="${W / 2}" y="${footerY + 14}" fill="${t.text5}" font-size="11" font-family=${F} text-anchor="middle">${esc(username)}</text>` : '';
|
|
113
304
|
|
|
114
|
-
|
|
115
|
-
<
|
|
116
|
-
|
|
305
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 ${W} ${H_FIXED}">
|
|
306
|
+
<defs>
|
|
307
|
+
<linearGradient id="accentGrad" x1="0" y1="0" x2="1" y2="0">
|
|
308
|
+
<stop offset="0%" stop-color="#818cf8"/>
|
|
309
|
+
<stop offset="50%" stop-color="#a78bfa"/>
|
|
310
|
+
<stop offset="100%" stop-color="#c084fc"/>
|
|
311
|
+
</linearGradient>
|
|
312
|
+
</defs>
|
|
117
313
|
|
|
118
|
-
<!--
|
|
119
|
-
<
|
|
120
|
-
${
|
|
314
|
+
<!-- Background -->
|
|
315
|
+
<rect width="${W}" height="${H_FIXED}" rx="12" fill="${t.bg}"/>
|
|
316
|
+
<rect x="0.5" y="0.5" width="${W - 1}" height="${H_FIXED - 1}" rx="12" fill="none" stroke="${t.border}" stroke-width="1"/>
|
|
317
|
+
|
|
318
|
+
<!-- Title bar -->
|
|
319
|
+
<rect x="1" y="1" width="${W - 2}" height="38" rx="12" fill="${t.bg2}"/>
|
|
320
|
+
<rect x="1" y="22" width="${W - 2}" height="17" fill="${t.bg2}"/>
|
|
321
|
+
<circle cx="20" cy="20" r="5" fill="#ef4444" opacity="0.7"/>
|
|
322
|
+
<circle cx="36" cy="20" r="5" fill="#f59e0b" opacity="0.7"/>
|
|
323
|
+
<circle cx="52" cy="20" r="5" fill="#22c55e" opacity="0.7"/>
|
|
324
|
+
<text x="${W / 2}" y="25" fill="${t.text2}" font-size="13" font-weight="600" font-family=${F} text-anchor="middle">agentlytics.io</text>
|
|
325
|
+
|
|
326
|
+
<!-- Accent line -->
|
|
327
|
+
<rect x="${pad}" y="44" width="60" height="2" rx="1" fill="url(#accentGrad)"/>
|
|
328
|
+
|
|
329
|
+
<!-- Branding -->
|
|
330
|
+
<g transform="translate(${pad}, 52) scale(0.8)" stroke="#818cf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
|
331
|
+
<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>
|
|
332
|
+
</g>
|
|
333
|
+
<text x="${pad + 26}" y="68" fill="${t.text}" font-size="16" font-weight="bold" font-family=${F}>Agentlytics</text>
|
|
334
|
+
<text x="${pad + 148}" y="68" fill="${t.text4}" font-size="13" font-family=${F}>${projectName ? esc(projectName) : 'Your AI coding stats'}</text>
|
|
335
|
+
<text x="${W - pad}" y="68" fill="${t.text5}" font-size="12" font-family=${F} text-anchor="end">${esc(dateStr)}</text>
|
|
336
|
+
|
|
337
|
+
<!-- Divider -->
|
|
338
|
+
<line x1="${pad}" y1="78" x2="${W - pad}" y2="78" stroke="${t.border}" stroke-width="1"/>
|
|
121
339
|
|
|
122
|
-
<!--
|
|
123
|
-
|
|
124
|
-
<polyline points="${sparkPoints}" fill="none" stroke="#888" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
|
|
125
|
-
<text x="590" y="${180 + sparkH + 14}" fill="#555" font-size="8" font-family="${F}">00:00</text>
|
|
126
|
-
<text x="${590 + sparkW - 28}" y="${180 + sparkH + 14}" fill="#555" font-size="8" font-family="${F}">23:00</text>
|
|
340
|
+
<!-- KPI cards -->
|
|
341
|
+
${kpiCards.join('')}
|
|
127
342
|
|
|
128
|
-
<!--
|
|
129
|
-
|
|
130
|
-
${modelsList}
|
|
343
|
+
<!-- Sections -->
|
|
344
|
+
${sectionSvgs.join('')}
|
|
131
345
|
|
|
132
|
-
<!-- Token
|
|
133
|
-
|
|
134
|
-
<text x="24" y="${H - 44}" fill="#666" font-size="9" font-family="${F}">in:${fmt(tk.input)} out:${fmt(tk.output)} cache:${fmt(tk.cacheRead)} tools:${fmt(stats.totalToolCalls || 0)} editors:${editors.length}</text>
|
|
346
|
+
<!-- Token footer -->
|
|
347
|
+
${tokenLine}
|
|
135
348
|
|
|
136
349
|
<!-- Footer -->
|
|
137
|
-
<line x1="
|
|
138
|
-
<text x="
|
|
139
|
-
|
|
350
|
+
<line x1="${pad}" y1="${footerY}" x2="${W - pad}" y2="${footerY}" stroke="${t.border}" stroke-width="1"/>
|
|
351
|
+
<text x="${pad}" y="${footerY + 14}" fill="${t.text5}" font-size="11" font-family=${F}>github.com/f/agentlytics</text>
|
|
352
|
+
${usernameText}
|
|
353
|
+
<text x="${W - pad}" y="${footerY + 14}" fill="${t.text5}" font-size="11" font-family=${F} text-anchor="end">npx agentlytics</text>
|
|
140
354
|
</svg>`;
|
|
141
355
|
|
|
142
356
|
return svg;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { X, Download, Share2, BarChart3, DollarSign, Clock, Cpu, Braces, User, Sun, Moon, FolderOpen } from 'lucide-react'
|
|
3
|
+
import { fetchShareImage, fetchProjects } from '../lib/api'
|
|
4
|
+
|
|
5
|
+
const TOGGLE_ITEMS = [
|
|
6
|
+
{ key: 'showEditors', label: 'Editors', icon: BarChart3 },
|
|
7
|
+
{ key: 'showCosts', label: 'Est. Costs', icon: DollarSign },
|
|
8
|
+
{ key: 'showHours', label: 'Peak Hours', icon: Clock },
|
|
9
|
+
{ key: 'showModels', label: 'Top Models', icon: Cpu },
|
|
10
|
+
{ key: 'showTokens', label: 'Token Footer', icon: Braces },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export default function ShareModal({ open, onClose }) {
|
|
14
|
+
const [opts, setOpts] = useState({
|
|
15
|
+
showEditors: true,
|
|
16
|
+
showCosts: true,
|
|
17
|
+
showHours: true,
|
|
18
|
+
showModels: true,
|
|
19
|
+
showTokens: true,
|
|
20
|
+
username: '',
|
|
21
|
+
theme: 'dark',
|
|
22
|
+
})
|
|
23
|
+
const [svg, setSvg] = useState('')
|
|
24
|
+
const [loading, setLoading] = useState(false)
|
|
25
|
+
const [downloading, setDownloading] = useState(false)
|
|
26
|
+
const [projects, setProjects] = useState([])
|
|
27
|
+
const debounceRef = useRef(null)
|
|
28
|
+
const backdropRef = useRef(null)
|
|
29
|
+
|
|
30
|
+
const loadPreview = useCallback(async (currentOpts) => {
|
|
31
|
+
setLoading(true)
|
|
32
|
+
try {
|
|
33
|
+
const result = await fetchShareImage(currentOpts)
|
|
34
|
+
if (result && !result.startsWith('{')) setSvg(result)
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error('Preview failed:', e)
|
|
37
|
+
}
|
|
38
|
+
setLoading(false)
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!open) return
|
|
43
|
+
loadPreview(opts)
|
|
44
|
+
fetchProjects().then(p => setProjects(p || [])).catch(() => {})
|
|
45
|
+
}, [open])
|
|
46
|
+
|
|
47
|
+
const updateOpt = (key, value) => {
|
|
48
|
+
const next = { ...opts, [key]: value }
|
|
49
|
+
setOpts(next)
|
|
50
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
51
|
+
debounceRef.current = setTimeout(() => loadPreview(next), 300)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleDownloadPng = async () => {
|
|
55
|
+
if (!svg) return
|
|
56
|
+
setDownloading(true)
|
|
57
|
+
try {
|
|
58
|
+
const scale = 2
|
|
59
|
+
const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })
|
|
60
|
+
const url = URL.createObjectURL(svgBlob)
|
|
61
|
+
const img = new Image()
|
|
62
|
+
img.width = 1200 * scale
|
|
63
|
+
img.height = 675 * scale
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
img.onload = resolve
|
|
66
|
+
img.onerror = reject
|
|
67
|
+
img.src = url
|
|
68
|
+
})
|
|
69
|
+
const canvas = document.createElement('canvas')
|
|
70
|
+
canvas.width = 1200 * scale
|
|
71
|
+
canvas.height = 675 * scale
|
|
72
|
+
const ctx = canvas.getContext('2d')
|
|
73
|
+
ctx.drawImage(img, 0, 0, 1200 * scale, 675 * scale)
|
|
74
|
+
URL.revokeObjectURL(url)
|
|
75
|
+
canvas.toBlob((blob) => {
|
|
76
|
+
if (!blob) return
|
|
77
|
+
const a = document.createElement('a')
|
|
78
|
+
a.href = URL.createObjectURL(blob)
|
|
79
|
+
a.download = 'agentlytics.png'
|
|
80
|
+
a.click()
|
|
81
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000)
|
|
82
|
+
}, 'image/png')
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error('PNG conversion failed:', e)
|
|
85
|
+
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
|
86
|
+
const a = document.createElement('a')
|
|
87
|
+
a.href = URL.createObjectURL(blob)
|
|
88
|
+
a.download = 'agentlytics.svg'
|
|
89
|
+
a.click()
|
|
90
|
+
URL.revokeObjectURL(a.href)
|
|
91
|
+
}
|
|
92
|
+
setDownloading(false)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const handleDownloadSvg = () => {
|
|
96
|
+
if (!svg) return
|
|
97
|
+
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
|
98
|
+
const a = document.createElement('a')
|
|
99
|
+
a.href = URL.createObjectURL(blob)
|
|
100
|
+
a.download = 'agentlytics.svg'
|
|
101
|
+
a.click()
|
|
102
|
+
URL.revokeObjectURL(a.href)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const handleShareTwitter = async () => {
|
|
106
|
+
await handleDownloadPng()
|
|
107
|
+
const text = encodeURIComponent("Here's my agentic coding stats using github.com/f/agentlytics")
|
|
108
|
+
window.open(`https://x.com/intent/post?text=${text}`, '_blank')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!open) return null
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
ref={backdropRef}
|
|
116
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
117
|
+
style={{ background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(8px)' }}
|
|
118
|
+
onClick={(e) => e.target === backdropRef.current && onClose()}
|
|
119
|
+
>
|
|
120
|
+
<div
|
|
121
|
+
className="w-full relative flex flex-col"
|
|
122
|
+
style={{
|
|
123
|
+
maxWidth: 960,
|
|
124
|
+
maxHeight: '90vh',
|
|
125
|
+
background: 'var(--c-bg)',
|
|
126
|
+
border: '1px solid var(--c-border)',
|
|
127
|
+
borderRadius: 12,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{/* Header */}
|
|
131
|
+
<div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
132
|
+
<div className="flex items-center gap-2">
|
|
133
|
+
<Share2 size={14} style={{ color: 'var(--c-accent)' }} />
|
|
134
|
+
<span className="text-[13px] font-semibold" style={{ color: 'var(--c-white)' }}>Share Stats</span>
|
|
135
|
+
</div>
|
|
136
|
+
<button onClick={onClose} className="p-1 rounded hover:opacity-70 transition" style={{ color: 'var(--c-text2)' }}>
|
|
137
|
+
<X size={16} />
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Body */}
|
|
142
|
+
<div className="flex-1 overflow-auto p-5" style={{ minHeight: 0 }}>
|
|
143
|
+
<div className="flex gap-5" style={{ flexDirection: 'row' }}>
|
|
144
|
+
|
|
145
|
+
{/* Sidebar: toggles */}
|
|
146
|
+
<div className="flex-shrink-0" style={{ width: 200 }}>
|
|
147
|
+
<div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
148
|
+
Customize
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div className="mb-4">
|
|
152
|
+
<div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
153
|
+
Project
|
|
154
|
+
</div>
|
|
155
|
+
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md" style={{ border: '1px solid var(--c-border)', background: 'var(--c-bg)' }}>
|
|
156
|
+
<FolderOpen size={11} style={{ color: 'var(--c-text3)' }} />
|
|
157
|
+
<select
|
|
158
|
+
value={opts.folder || ''}
|
|
159
|
+
onChange={(e) => updateOpt('folder', e.target.value || undefined)}
|
|
160
|
+
className="bg-transparent outline-none text-[12px] w-full cursor-pointer"
|
|
161
|
+
style={{ color: 'var(--c-text)', appearance: 'none' }}
|
|
162
|
+
>
|
|
163
|
+
<option value="">All Projects</option>
|
|
164
|
+
{projects.map(p => (
|
|
165
|
+
<option key={p.folder} value={p.folder}>
|
|
166
|
+
{p.name} ({p.totalSessions})
|
|
167
|
+
</option>
|
|
168
|
+
))}
|
|
169
|
+
</select>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className="space-y-1.5">
|
|
174
|
+
{TOGGLE_ITEMS.map(({ key, label, icon: Icon }) => {
|
|
175
|
+
const active = opts[key]
|
|
176
|
+
return (
|
|
177
|
+
<button
|
|
178
|
+
key={key}
|
|
179
|
+
onClick={() => updateOpt(key, !active)}
|
|
180
|
+
className="flex items-center gap-2 w-full px-2.5 py-2 rounded-md text-[12px] transition"
|
|
181
|
+
style={{
|
|
182
|
+
background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
|
|
183
|
+
border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
|
|
184
|
+
color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
|
|
185
|
+
opacity: active ? 1 : 0.6,
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<Icon size={12} />
|
|
189
|
+
{label}
|
|
190
|
+
</button>
|
|
191
|
+
)
|
|
192
|
+
})}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div className="mt-4">
|
|
196
|
+
<div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
197
|
+
Theme
|
|
198
|
+
</div>
|
|
199
|
+
<div className="flex gap-1.5">
|
|
200
|
+
{[{ key: 'dark', icon: Moon, label: 'Dark' }, { key: 'light', icon: Sun, label: 'Light' }].map(({ key, icon: Icon, label }) => {
|
|
201
|
+
const active = opts.theme === key
|
|
202
|
+
return (
|
|
203
|
+
<button
|
|
204
|
+
key={key}
|
|
205
|
+
onClick={() => updateOpt('theme', key)}
|
|
206
|
+
className="flex items-center gap-1.5 flex-1 justify-center px-2 py-1.5 rounded-md text-[11px] transition"
|
|
207
|
+
style={{
|
|
208
|
+
background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
|
|
209
|
+
border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
|
|
210
|
+
color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
|
|
211
|
+
opacity: active ? 1 : 0.6,
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
<Icon size={11} />
|
|
215
|
+
{label}
|
|
216
|
+
</button>
|
|
217
|
+
)
|
|
218
|
+
})}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<div className="mt-4">
|
|
223
|
+
<div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
224
|
+
Username
|
|
225
|
+
</div>
|
|
226
|
+
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md" style={{ border: '1px solid var(--c-border)', background: 'var(--c-bg)' }}>
|
|
227
|
+
<User size={11} style={{ color: 'var(--c-text3)' }} />
|
|
228
|
+
<input
|
|
229
|
+
type="text"
|
|
230
|
+
placeholder="optional"
|
|
231
|
+
value={opts.username}
|
|
232
|
+
onChange={(e) => updateOpt('username', e.target.value)}
|
|
233
|
+
className="bg-transparent outline-none text-[12px] w-full"
|
|
234
|
+
style={{ color: 'var(--c-text)' }}
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{/* Preview */}
|
|
241
|
+
<div className="flex-1 min-w-0">
|
|
242
|
+
<div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
243
|
+
Preview
|
|
244
|
+
</div>
|
|
245
|
+
<div
|
|
246
|
+
className="rounded-lg overflow-hidden relative"
|
|
247
|
+
style={{
|
|
248
|
+
border: '1px solid var(--c-border)',
|
|
249
|
+
background: '#09090f',
|
|
250
|
+
minHeight: 200,
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{loading && (
|
|
254
|
+
<div className="absolute inset-0 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.5)', zIndex: 2 }}>
|
|
255
|
+
<span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Generating...</span>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
{svg && (
|
|
259
|
+
<div
|
|
260
|
+
className="w-full [&>svg]:w-full [&>svg]:h-auto [&>svg]:block"
|
|
261
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
262
|
+
/>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Footer: actions */}
|
|
270
|
+
<div className="flex items-center justify-between px-5 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
271
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
|
|
272
|
+
Tip: Toggle sections to customize your share card
|
|
273
|
+
</span>
|
|
274
|
+
<div className="flex items-center gap-2">
|
|
275
|
+
<button
|
|
276
|
+
onClick={handleDownloadSvg}
|
|
277
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
|
|
278
|
+
style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
|
|
279
|
+
>
|
|
280
|
+
<Download size={12} />
|
|
281
|
+
SVG
|
|
282
|
+
</button>
|
|
283
|
+
<button
|
|
284
|
+
onClick={handleDownloadPng}
|
|
285
|
+
disabled={downloading}
|
|
286
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
|
|
287
|
+
style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)', opacity: downloading ? 0.5 : 1 }}
|
|
288
|
+
>
|
|
289
|
+
<Download size={12} />
|
|
290
|
+
{downloading ? 'Converting...' : 'PNG'}
|
|
291
|
+
</button>
|
|
292
|
+
<button
|
|
293
|
+
onClick={handleShareTwitter}
|
|
294
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
|
|
295
|
+
style={{ background: '#6366f1', color: '#fff' }}
|
|
296
|
+
>
|
|
297
|
+
<Share2 size={12} />
|
|
298
|
+
Share on X
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
package/ui/src/lib/api.js
CHANGED
|
@@ -180,8 +180,18 @@ export async function fetchSchema() {
|
|
|
180
180
|
return res.json();
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
export async function fetchShareImage() {
|
|
184
|
-
const
|
|
183
|
+
export async function fetchShareImage(opts = {}) {
|
|
184
|
+
const q = new URLSearchParams();
|
|
185
|
+
if (opts.showEditors !== undefined) q.set('showEditors', opts.showEditors);
|
|
186
|
+
if (opts.showModels !== undefined) q.set('showModels', opts.showModels);
|
|
187
|
+
if (opts.showCosts !== undefined) q.set('showCosts', opts.showCosts);
|
|
188
|
+
if (opts.showTokens !== undefined) q.set('showTokens', opts.showTokens);
|
|
189
|
+
if (opts.showHours !== undefined) q.set('showHours', opts.showHours);
|
|
190
|
+
if (opts.username) q.set('username', opts.username);
|
|
191
|
+
if (opts.theme) q.set('theme', opts.theme);
|
|
192
|
+
if (opts.folder) q.set('folder', opts.folder);
|
|
193
|
+
const qs = q.toString();
|
|
194
|
+
const res = await fetch(`${BASE}/api/share-image${qs ? '?' + qs : ''}`);
|
|
185
195
|
return res.text();
|
|
186
196
|
}
|
|
187
197
|
|
|
@@ -8,8 +8,9 @@ import ActivityHeatmap from '../components/ActivityHeatmap'
|
|
|
8
8
|
import DateRangePicker from '../components/DateRangePicker'
|
|
9
9
|
import { editorColor, editorLabel, formatNumber, formatCost, dateRangeToApiParams } from '../lib/constants'
|
|
10
10
|
import EditorIcon from '../components/EditorIcon'
|
|
11
|
-
import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats,
|
|
11
|
+
import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchChats, fetchCosts } from '../lib/api'
|
|
12
12
|
import ChatSidebar from '../components/ChatSidebar'
|
|
13
|
+
import ShareModal from '../components/ShareModal'
|
|
13
14
|
import { useTheme } from '../lib/theme'
|
|
14
15
|
import SectionTitle from '../components/SectionTitle'
|
|
15
16
|
|
|
@@ -32,7 +33,7 @@ export default function Dashboard({ overview }) {
|
|
|
32
33
|
const [dateRange, setDateRange] = useState(null)
|
|
33
34
|
const { dark } = useTheme()
|
|
34
35
|
const [costs, setCosts] = useState(null)
|
|
35
|
-
const [
|
|
36
|
+
const [shareOpen, setShareOpen] = useState(false)
|
|
36
37
|
const [largeContextChats, setLargeContextChats] = useState(null)
|
|
37
38
|
const [selectedChatId, setSelectedChatId] = useState(null)
|
|
38
39
|
const txtColor = dark ? '#888' : '#555'
|
|
@@ -169,54 +170,6 @@ export default function Dashboard({ overview }) {
|
|
|
169
170
|
return s + v * midpoints[i]
|
|
170
171
|
}, 0) / tk.sessions).toFixed(1) : '—') : '—'
|
|
171
172
|
|
|
172
|
-
const handleShare = async () => {
|
|
173
|
-
setSharing(true)
|
|
174
|
-
try {
|
|
175
|
-
const svg = await fetchShareImage()
|
|
176
|
-
if (!svg || svg.startsWith('{')) throw new Error('Failed to fetch image')
|
|
177
|
-
|
|
178
|
-
// Try PNG conversion via canvas, fallback to SVG download
|
|
179
|
-
let downloaded = false
|
|
180
|
-
try {
|
|
181
|
-
const canvas = document.createElement('canvas')
|
|
182
|
-
canvas.width = 1600
|
|
183
|
-
canvas.height = 880
|
|
184
|
-
const ctx = canvas.getContext('2d')
|
|
185
|
-
const img = new Image()
|
|
186
|
-
const svgB64 = btoa(unescape(encodeURIComponent(svg)))
|
|
187
|
-
const dataUrl = `data:image/svg+xml;base64,${svgB64}`
|
|
188
|
-
await new Promise((resolve, reject) => {
|
|
189
|
-
img.onload = resolve
|
|
190
|
-
img.onerror = reject
|
|
191
|
-
img.src = dataUrl
|
|
192
|
-
})
|
|
193
|
-
ctx.drawImage(img, 0, 0, 1600, 880)
|
|
194
|
-
const pngUrl = canvas.toDataURL('image/png')
|
|
195
|
-
const a = document.createElement('a')
|
|
196
|
-
a.href = pngUrl
|
|
197
|
-
a.download = 'agentlytics.png'
|
|
198
|
-
a.click()
|
|
199
|
-
downloaded = true
|
|
200
|
-
} catch {
|
|
201
|
-
// Fallback: download SVG directly
|
|
202
|
-
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
|
203
|
-
const a = document.createElement('a')
|
|
204
|
-
a.href = URL.createObjectURL(blob)
|
|
205
|
-
a.download = 'agentlytics.svg'
|
|
206
|
-
a.click()
|
|
207
|
-
URL.revokeObjectURL(a.href)
|
|
208
|
-
downloaded = true
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (downloaded) {
|
|
212
|
-
const text = encodeURIComponent("Here's my agentic coding stats using github.com/f/agentlytics")
|
|
213
|
-
window.open(`https://x.com/intent/post?text=${text}`, '_blank')
|
|
214
|
-
}
|
|
215
|
-
} catch (e) {
|
|
216
|
-
console.error('Share failed:', e)
|
|
217
|
-
}
|
|
218
|
-
setSharing(false)
|
|
219
|
-
}
|
|
220
173
|
|
|
221
174
|
return (
|
|
222
175
|
<div className="fade-in space-y-3">
|
|
@@ -224,13 +177,12 @@ export default function Dashboard({ overview }) {
|
|
|
224
177
|
<div className="flex items-center justify-end gap-3">
|
|
225
178
|
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
|
226
179
|
<button
|
|
227
|
-
onClick={
|
|
228
|
-
disabled={sharing}
|
|
180
|
+
onClick={() => setShareOpen(true)}
|
|
229
181
|
className="flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-md transition hover:opacity-80"
|
|
230
|
-
style={{ background: '#6366f1', color: '#fff'
|
|
182
|
+
style={{ background: '#6366f1', color: '#fff' }}
|
|
231
183
|
>
|
|
232
184
|
<Share2 size={12} />
|
|
233
|
-
|
|
185
|
+
Share Stats
|
|
234
186
|
</button>
|
|
235
187
|
</div>
|
|
236
188
|
|
|
@@ -561,6 +513,7 @@ export default function Dashboard({ overview }) {
|
|
|
561
513
|
)}
|
|
562
514
|
</div>
|
|
563
515
|
<ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
|
|
516
|
+
<ShareModal open={shareOpen} onClose={() => setShareOpen(false)} />
|
|
564
517
|
</div>
|
|
565
518
|
)
|
|
566
519
|
}
|