openclacky 1.2.15 → 1.2.17
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/lib/clacky/agent/session_serializer.rb +5 -2
- data/lib/clacky/default_skills/channel-manager/SKILL.md +4 -2
- data/lib/clacky/providers.rb +3 -0
- data/lib/clacky/server/http_server.rb +32 -3
- data/lib/clacky/telemetry.rb +20 -0
- data/lib/clacky/utils/model_pricing.rb +17 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +305 -0
- data/lib/clacky/web/billing.js +144 -0
- data/lib/clacky/web/i18n.js +146 -3
- data/lib/clacky/web/index.html +11 -0
- data/lib/clacky/web/marked.min.js +55 -45
- data/lib/clacky/web/model-tester.js +2 -1
- data/lib/clacky/web/sessions.js +6 -1
- data/lib/clacky/web/settings.js +9 -6
- data/lib/clacky/web/share.js +843 -0
- data/lib/clacky/web/vendor/qrcode/qrcode.min.js +8 -0
- data/lib/clacky/web/ws-dispatcher.js +1 -0
- data/scripts/install.ps1 +48 -21
- metadata +4 -2
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
// share.js — Share hooks for spreading the word at peak-delight moments.
|
|
2
|
+
//
|
|
3
|
+
// Two entry points, one shared UI:
|
|
4
|
+
// Share.openModal() — always-available header button
|
|
5
|
+
// Share.maybePromptOnComplete() — fires after a task succeeds, gated by
|
|
6
|
+
// a frequency cap (first + 5th success,
|
|
7
|
+
// then a 7-day cooldown after dismissal)
|
|
8
|
+
//
|
|
9
|
+
// Brand-aware: when this install is white-labelled, ALL share content
|
|
10
|
+
// (product name, landing link, QR target, logo) switches to the brand's
|
|
11
|
+
// values. It NEVER leaks "OpenClacky" / openclacky.com into a branded build.
|
|
12
|
+
//
|
|
13
|
+
// Depends on: qrcode (vendor/qrcode/qrcode.min.js), I18n, Modal.toast.
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
15
|
+
const Share = (() => {
|
|
16
|
+
const DEFAULT_NAME = "OpenClacky";
|
|
17
|
+
const DEFAULT_HOMEPAGE = "https://www.openclacky.com/";
|
|
18
|
+
|
|
19
|
+
const COUNT_KEY = "clacky-share-success-count";
|
|
20
|
+
const COOLDOWN_KEY = "clacky-share-cooldown-until";
|
|
21
|
+
const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
+
const PROMPT_AT = [1, 5]; // success counts that trigger an auto-prompt
|
|
23
|
+
|
|
24
|
+
const THEME_KEY = "clacky-share-theme"; // remembers last picked poster style
|
|
25
|
+
const DRAFT_KEY = "clacky-share-draft"; // remembers last hand-edited text
|
|
26
|
+
|
|
27
|
+
// Poster themes: each is a palette the canvas renderers read from, so the
|
|
28
|
+
// layout stays shared and only colors change. Order = picker order.
|
|
29
|
+
const THEMES = {
|
|
30
|
+
geek: {
|
|
31
|
+
labelKey: "share.theme.geek", style: "geek",
|
|
32
|
+
bg: ["#0f172a", "#1e293b"], scoreBg: ["#0b1220", "#13243b"],
|
|
33
|
+
title: "#ffffff", tagline: "#f1f5f9", period: "#7dd3fc",
|
|
34
|
+
hero: "#38bdf8", metric: "#ffffff", metricLabel: "#cbd5e1",
|
|
35
|
+
golden: "#fcd34d", brand: "#e2e8f0", scan: "#94a3b8", swatch: "#1e293b",
|
|
36
|
+
grid: "rgba(125,211,252,0.06)",
|
|
37
|
+
heat: { empty: "rgba(255,255,255,0.06)", scale: ["#0e3a52", "#0a6e96", "#1aa3d6", "#38bdf8", "#7dd3fc"] },
|
|
38
|
+
glows: [
|
|
39
|
+
{ x: 0.20, y: 0.18, r: 0.55, color: "rgba(56,189,248,0.22)" },
|
|
40
|
+
{ x: 0.85, y: 0.42, r: 0.50, color: "rgba(168,85,247,0.18)" },
|
|
41
|
+
{ x: 0.50, y: 0.92, r: 0.60, color: "rgba(14,165,233,0.14)" }
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
light: {
|
|
45
|
+
labelKey: "share.theme.light", style: "light",
|
|
46
|
+
bg: ["#f8fafc", "#e2e8f0"], scoreBg: ["#ffffff", "#eef2f7"],
|
|
47
|
+
title: "#0f172a", tagline: "#475569", period: "#0284c7",
|
|
48
|
+
hero: "#0284c7", metric: "#0f172a", metricLabel: "#64748b",
|
|
49
|
+
golden: "#b45309", brand: "#334155", scan: "#94a3b8", swatch: "#e2e8f0",
|
|
50
|
+
grid: "rgba(2,132,199,0.05)",
|
|
51
|
+
heat: { empty: "rgba(2,132,199,0.08)", scale: ["#bae6fd", "#7dd3fc", "#38bdf8", "#0ea5e9", "#0369a1"] },
|
|
52
|
+
glows: [
|
|
53
|
+
{ x: 0.18, y: 0.16, r: 0.55, color: "rgba(125,211,252,0.40)" },
|
|
54
|
+
{ x: 0.88, y: 0.38, r: 0.50, color: "rgba(196,181,253,0.38)" },
|
|
55
|
+
{ x: 0.55, y: 0.95, r: 0.60, color: "rgba(167,243,208,0.34)" }
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
warm: {
|
|
59
|
+
labelKey: "share.theme.warm", style: "warm",
|
|
60
|
+
bg: ["#fff1eb", "#ffd9c0"], scoreBg: ["#fff5f0", "#ffe0cc"],
|
|
61
|
+
title: "#7c2d12", tagline: "#9a3412", period: "#ea580c",
|
|
62
|
+
hero: "#ea580c", metric: "#7c2d12", metricLabel: "#c2410c",
|
|
63
|
+
golden: "#be123c", brand: "#9a3412", scan: "#c2410c", swatch: "#ffd9c0",
|
|
64
|
+
grid: "rgba(234,88,12,0.05)",
|
|
65
|
+
heat: { empty: "rgba(234,88,12,0.08)", scale: ["#fed7aa", "#fdba74", "#fb923c", "#f97316", "#c2410c"] },
|
|
66
|
+
glows: [
|
|
67
|
+
{ x: 0.16, y: 0.18, r: 0.58, color: "rgba(251,146,60,0.45)" },
|
|
68
|
+
{ x: 0.90, y: 0.40, r: 0.52, color: "rgba(244,114,182,0.38)" },
|
|
69
|
+
{ x: 0.52, y: 0.94, r: 0.62, color: "rgba(250,204,21,0.40)" }
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const THEME_ORDER = ["geek", "light", "warm"];
|
|
74
|
+
|
|
75
|
+
function _themeId() {
|
|
76
|
+
const saved = localStorage.getItem(THEME_KEY);
|
|
77
|
+
return THEMES[saved] ? saved : "geek";
|
|
78
|
+
}
|
|
79
|
+
function _theme() { return THEMES[_themeId()]; }
|
|
80
|
+
function _setTheme(id) { if (THEMES[id]) localStorage.setItem(THEME_KEY, id); }
|
|
81
|
+
|
|
82
|
+
// Brand info, hydrated once from /api/brand/status. Falls back to defaults
|
|
83
|
+
// (un-branded OpenClacky) until the fetch resolves or if it fails.
|
|
84
|
+
let _brand = { name: DEFAULT_NAME, homepageUrl: DEFAULT_HOMEPAGE, logoUrl: null };
|
|
85
|
+
|
|
86
|
+
// Current scorecard stats, set by openScorecard() before building the
|
|
87
|
+
// scorecard poster / copy. Null in plain product-share mode.
|
|
88
|
+
// Shape: { periods: { day|week|month: {period,cacheHitRate,costStr,...} },
|
|
89
|
+
// defaultPeriod, heatmap: [{date,tokens}], ...flat fallback }
|
|
90
|
+
let _scorecard = null;
|
|
91
|
+
// Currently selected scorecard period key (day | week | month).
|
|
92
|
+
let _scorePeriod = null;
|
|
93
|
+
// Set while a scorecard modal is open; lets billing hot-swap in late-arriving
|
|
94
|
+
// period data and re-render the period switcher.
|
|
95
|
+
let _rerenderPeriods = null;
|
|
96
|
+
|
|
97
|
+
// ── Brand source (single source of truth) ─────────────────────────────
|
|
98
|
+
async function _hydrateBrand() {
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch("/api/brand/status");
|
|
101
|
+
if (!res.ok) return;
|
|
102
|
+
const data = await res.json();
|
|
103
|
+
if (data && data.branded) {
|
|
104
|
+
_brand = {
|
|
105
|
+
name: (data.product_name || "").trim() || DEFAULT_NAME,
|
|
106
|
+
// Branded builds must NEVER fall back to openclacky.com. If the brand
|
|
107
|
+
// has no homepage configured, we simply show no link / QR.
|
|
108
|
+
homepageUrl: (data.homepage_url || "").trim() || null,
|
|
109
|
+
logoUrl: (data.logo_url || "").trim() || null
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
} catch (_e) { /* keep defaults */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Share copy (i18n + brand interpolation) ───────────────────────────
|
|
116
|
+
// Candidate variants per platform; the UI shuffles among them and lets the
|
|
117
|
+
// user hand-edit before posting. Product mode shares one generic pool
|
|
118
|
+
// (`share.copy.*`); scorecard mode has per-platform pools with live numbers.
|
|
119
|
+
function _candidatesFor(platform) {
|
|
120
|
+
if (_scorecard) {
|
|
121
|
+
const list = I18n.tList("share.scorecard.copy." + platform, _scorecardVars());
|
|
122
|
+
return list.length ? list : [_scorecardCopy("copylink")];
|
|
123
|
+
}
|
|
124
|
+
const list = I18n.tList("share.copy", { brand: _brand.name });
|
|
125
|
+
return list.length ? list : [I18n.t("share.copy.1", { brand: _brand.name })];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _pickCopy(platform, exclude) {
|
|
129
|
+
const list = _candidatesFor(platform).map((s) => s.trim());
|
|
130
|
+
if (list.length <= 1) return list[0] || "";
|
|
131
|
+
let pick = list[Math.floor(Math.random() * list.length)];
|
|
132
|
+
if (exclude != null) {
|
|
133
|
+
let guard = 0;
|
|
134
|
+
while (pick === exclude && guard++ < 8) {
|
|
135
|
+
pick = list[Math.floor(Math.random() * list.length)];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return pick;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _shareUrl() {
|
|
142
|
+
return _brand.homepageUrl; // null when branded build has no homepage
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Scorecard copy (per-platform, numbers from stats) ─────────────────
|
|
146
|
+
// Returns the stats object for the currently-selected period, falling back
|
|
147
|
+
// to the flat fields billing.js passes for back-compat.
|
|
148
|
+
function _curStats() {
|
|
149
|
+
if (_scorecard.periods && _scorePeriod && _scorecard.periods[_scorePeriod]) {
|
|
150
|
+
return _scorecard.periods[_scorePeriod];
|
|
151
|
+
}
|
|
152
|
+
return _scorecard;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _scorecardVars() {
|
|
156
|
+
const s = _curStats();
|
|
157
|
+
return {
|
|
158
|
+
brand: _brand.name,
|
|
159
|
+
period: s.period,
|
|
160
|
+
cacheHitRate: s.cacheHitRate,
|
|
161
|
+
cost: s.costStr,
|
|
162
|
+
tokens: s.tokensStr,
|
|
163
|
+
requests: s.requests
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _scorecardCopy(platform) {
|
|
168
|
+
return I18n.t("share.scorecard.copy." + platform + ".1", _scorecardVars()).trim();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Pick a dynamic golden line based on how strong the numbers are.
|
|
172
|
+
function _scorecardGoldenLine() {
|
|
173
|
+
const rate = parseFloat(_curStats().cacheHitRate) || 0;
|
|
174
|
+
const key = rate >= 90 ? "high" : rate >= 60 ? "mid" : "low";
|
|
175
|
+
return I18n.t("share.scorecard.golden." + key, _scorecardVars());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Frequency cap (C2: first + 5th, 7-day cooldown after dismissal) ────
|
|
179
|
+
function _successCount() {
|
|
180
|
+
return parseInt(localStorage.getItem(COUNT_KEY) || "0", 10) || 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _bumpSuccessCount() {
|
|
184
|
+
const n = _successCount() + 1;
|
|
185
|
+
localStorage.setItem(COUNT_KEY, String(n));
|
|
186
|
+
return n;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _inCooldown() {
|
|
190
|
+
const until = parseInt(localStorage.getItem(COOLDOWN_KEY) || "0", 10) || 0;
|
|
191
|
+
return Date.now() < until;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _startCooldown() {
|
|
195
|
+
localStorage.setItem(COOLDOWN_KEY, String(Date.now() + COOLDOWN_MS));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── QR code (reuses qrcode-generator) ─────────────────────────────────
|
|
199
|
+
// Draws the QR for `url` onto a canvas 2d context at (x, y) sized `sizePx`.
|
|
200
|
+
function _drawQrToCanvas(ctx, url, x, y, sizePx) {
|
|
201
|
+
const qr = qrcode(0, "M");
|
|
202
|
+
qr.addData(url);
|
|
203
|
+
qr.make();
|
|
204
|
+
const count = qr.getModuleCount();
|
|
205
|
+
const quiet = 2;
|
|
206
|
+
const total = count + quiet * 2;
|
|
207
|
+
const module = sizePx / total;
|
|
208
|
+
|
|
209
|
+
ctx.fillStyle = "#ffffff";
|
|
210
|
+
ctx.fillRect(x, y, sizePx, sizePx);
|
|
211
|
+
ctx.fillStyle = "#1a1a1a";
|
|
212
|
+
for (let r = 0; r < count; r++) {
|
|
213
|
+
for (let c = 0; c < count; c++) {
|
|
214
|
+
if (qr.isDark(r, c)) {
|
|
215
|
+
ctx.fillRect(
|
|
216
|
+
x + (c + quiet) * module,
|
|
217
|
+
y + (r + quiet) * module,
|
|
218
|
+
Math.ceil(module),
|
|
219
|
+
Math.ceil(module)
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Poster (pure-frontend Canvas, zero token, instant) ────────────────
|
|
227
|
+
// Returns a data URL (PNG). `copy` is the live editor text, drawn as the
|
|
228
|
+
// poster's main line so the poster matches exactly what the user will post.
|
|
229
|
+
function _buildPoster(copy) {
|
|
230
|
+
const W = 720, H = 1080;
|
|
231
|
+
const t = _theme();
|
|
232
|
+
const canvas = document.createElement("canvas");
|
|
233
|
+
canvas.width = W;
|
|
234
|
+
canvas.height = H;
|
|
235
|
+
const ctx = canvas.getContext("2d");
|
|
236
|
+
|
|
237
|
+
// Background.
|
|
238
|
+
_paintBackground(ctx, W, H, t, t.bg);
|
|
239
|
+
|
|
240
|
+
// Brand name.
|
|
241
|
+
ctx.fillStyle = t.title;
|
|
242
|
+
ctx.textAlign = "center";
|
|
243
|
+
ctx.font = "700 60px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
244
|
+
ctx.fillText(_brand.name, W / 2, 150);
|
|
245
|
+
|
|
246
|
+
// Main line = the live editor text (so poster === shared text).
|
|
247
|
+
const text = (copy || "").trim() || I18n.t("share.poster.tagline", { brand: _brand.name });
|
|
248
|
+
ctx.fillStyle = t.tagline;
|
|
249
|
+
_drawAutoText(ctx, text, W / 2, 250, W - 120, 480 - 250);
|
|
250
|
+
|
|
251
|
+
// QR code (only when there's a URL to point at).
|
|
252
|
+
const url = _shareUrl();
|
|
253
|
+
if (url) {
|
|
254
|
+
const qrSize = 300;
|
|
255
|
+
const qrX = (W - qrSize) / 2;
|
|
256
|
+
const qrY = 560;
|
|
257
|
+
ctx.fillStyle = "#ffffff";
|
|
258
|
+
_roundRect(ctx, qrX - 24, qrY - 24, qrSize + 48, qrSize + 48, 22);
|
|
259
|
+
ctx.fill();
|
|
260
|
+
_drawQrToCanvas(ctx, url, qrX, qrY, qrSize);
|
|
261
|
+
|
|
262
|
+
ctx.fillStyle = t.scan;
|
|
263
|
+
ctx.font = "400 28px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
264
|
+
ctx.fillText(I18n.t("share.poster.scan"), W / 2, qrY + qrSize + 80);
|
|
265
|
+
|
|
266
|
+
ctx.fillStyle = t.scan;
|
|
267
|
+
ctx.font = "400 24px -apple-system, sans-serif";
|
|
268
|
+
ctx.fillText(url.replace(/^https?:\/\//, "").replace(/\/$/, ""), W / 2, H - 60);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return canvas.toDataURL("image/png");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Scorecard poster (B-line: spend / cache-hit bragging card) ────────
|
|
275
|
+
// Hero = the cache-hit rate (the delight number). Brand is a footer chip.
|
|
276
|
+
function _buildScorecardPoster(copy) {
|
|
277
|
+
const W = 720, H = 1080;
|
|
278
|
+
const t = _theme();
|
|
279
|
+
const canvas = document.createElement("canvas");
|
|
280
|
+
canvas.width = W;
|
|
281
|
+
canvas.height = H;
|
|
282
|
+
const ctx = canvas.getContext("2d");
|
|
283
|
+
|
|
284
|
+
_paintBackground(ctx, W, H, t, t.scoreBg);
|
|
285
|
+
|
|
286
|
+
ctx.textAlign = "center";
|
|
287
|
+
|
|
288
|
+
// Title + period.
|
|
289
|
+
ctx.fillStyle = t.title;
|
|
290
|
+
ctx.font = "700 48px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
291
|
+
ctx.fillText(I18n.t("share.scorecard.poster.title"), W / 2, 110);
|
|
292
|
+
|
|
293
|
+
const s = _curStats();
|
|
294
|
+
const showHeat = _scorePeriod === "month" && _scorecard.heatmap && _scorecard.heatmap.length > 0;
|
|
295
|
+
|
|
296
|
+
ctx.fillStyle = t.period;
|
|
297
|
+
ctx.font = "400 28px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
298
|
+
ctx.fillText(s.period, W / 2, 162);
|
|
299
|
+
|
|
300
|
+
// Hero: cache hit rate.
|
|
301
|
+
ctx.fillStyle = t.hero;
|
|
302
|
+
ctx.font = "800 150px -apple-system, 'PingFang SC', sans-serif";
|
|
303
|
+
ctx.fillText(s.cacheHitRate + "%", W / 2, 348);
|
|
304
|
+
|
|
305
|
+
ctx.fillStyle = t.tagline;
|
|
306
|
+
ctx.font = "400 32px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
307
|
+
ctx.fillText(I18n.t("share.scorecard.poster.cacheLabel"), W / 2, 408);
|
|
308
|
+
|
|
309
|
+
// Secondary metrics: cost + tokens.
|
|
310
|
+
ctx.fillStyle = t.metric;
|
|
311
|
+
ctx.font = "700 48px -apple-system, 'PingFang SC', sans-serif";
|
|
312
|
+
ctx.fillText(s.costStr, W / 2 - 160, 494);
|
|
313
|
+
ctx.fillText(s.tokensStr, W / 2 + 160, 494);
|
|
314
|
+
ctx.fillStyle = t.metricLabel;
|
|
315
|
+
ctx.font = "400 26px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
316
|
+
ctx.fillText(I18n.t("share.scorecard.poster.costLabel"), W / 2 - 160, 534);
|
|
317
|
+
ctx.fillText(I18n.t("share.scorecard.poster.tokensLabel"), W / 2 + 160, 534);
|
|
318
|
+
|
|
319
|
+
// Monthly heatmap (calendar style). Pushes everything below down.
|
|
320
|
+
let cursorY = 574;
|
|
321
|
+
if (showHeat) {
|
|
322
|
+
cursorY = _drawHeatmap(ctx, W, 552, t);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Golden line = live editor text (poster matches what gets posted).
|
|
326
|
+
const goldenTop = showHeat ? cursorY + 14 : 614;
|
|
327
|
+
const goldenH = showHeat ? 78 : 110;
|
|
328
|
+
const line = (copy || "").trim() || _scorecardGoldenLine();
|
|
329
|
+
ctx.fillStyle = t.golden;
|
|
330
|
+
_drawAutoText(ctx, line, W / 2, goldenTop, W - 120, goldenH);
|
|
331
|
+
|
|
332
|
+
// QR + brand chip (gated: branded builds with no homepage show neither).
|
|
333
|
+
const url = _shareUrl();
|
|
334
|
+
const qrSize = showHeat ? 152 : 230;
|
|
335
|
+
const qrY = goldenTop + goldenH + (showHeat ? 14 : 30);
|
|
336
|
+
if (url) {
|
|
337
|
+
const qrX = (W - qrSize) / 2;
|
|
338
|
+
ctx.fillStyle = "#ffffff";
|
|
339
|
+
_roundRect(ctx, qrX - 16, qrY - 16, qrSize + 32, qrSize + 32, 16);
|
|
340
|
+
ctx.fill();
|
|
341
|
+
_drawQrToCanvas(ctx, url, qrX, qrY, qrSize);
|
|
342
|
+
|
|
343
|
+
ctx.fillStyle = t.brand;
|
|
344
|
+
ctx.font = "600 28px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
345
|
+
ctx.fillText(_brand.name, W / 2, qrY + qrSize + (showHeat ? 40 : 52));
|
|
346
|
+
if (!showHeat) {
|
|
347
|
+
ctx.fillStyle = t.scan;
|
|
348
|
+
ctx.font = "400 23px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
349
|
+
ctx.fillText(I18n.t("share.scorecard.poster.scan"), W / 2, qrY + qrSize + 86);
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
ctx.fillStyle = t.brand;
|
|
353
|
+
ctx.font = "600 34px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
354
|
+
ctx.fillText(_brand.name, W / 2, qrY + 40);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return canvas.toDataURL("image/png");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Draws a GitHub-contribution-style heatmap of daily token usage. Cells are
|
|
361
|
+
// laid out in columns of 7 (one week per column), oldest → newest. Color
|
|
362
|
+
// Draws a calendar-style heatmap of daily token usage: 7 columns (Sun→Sat),
|
|
363
|
+
// one week per row, aligned to real weekdays. Color ramps with usage via the
|
|
364
|
+
// theme's `heat` scale. Returns the Y just below it.
|
|
365
|
+
function _drawHeatmap(ctx, W, top, t) {
|
|
366
|
+
const days = _scorecard.heatmap;
|
|
367
|
+
const heat = t.heat || { empty: "rgba(255,255,255,0.08)", scale: ["#9be9a8", "#40c463", "#30a14e", "#216e39", "#0a4020"] };
|
|
368
|
+
const cols = 7;
|
|
369
|
+
const gap = 6;
|
|
370
|
+
const cell = 30;
|
|
371
|
+
const gridW = cols * cell + (cols - 1) * gap;
|
|
372
|
+
const x0 = (W - gridW) / 2;
|
|
373
|
+
|
|
374
|
+
// Title above the grid.
|
|
375
|
+
ctx.textAlign = "center";
|
|
376
|
+
ctx.fillStyle = t.metricLabel;
|
|
377
|
+
ctx.font = "400 24px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
378
|
+
ctx.fillText(I18n.t("share.scorecard.poster.heatmapLabel"), W / 2, top);
|
|
379
|
+
|
|
380
|
+
const gridTop = top + 18;
|
|
381
|
+
const maxTok = Math.max.apply(null, days.map((d) => d.tokens).concat([1]));
|
|
382
|
+
|
|
383
|
+
// Weekday offset of the first day so columns line up with real weekdays.
|
|
384
|
+
const firstDow = days.length ? new Date(days[0].date + "T00:00:00").getDay() : 0;
|
|
385
|
+
let maxRow = 0;
|
|
386
|
+
|
|
387
|
+
days.forEach((d, i) => {
|
|
388
|
+
const slot = firstDow + i;
|
|
389
|
+
const col = slot % cols;
|
|
390
|
+
const row = Math.floor(slot / cols);
|
|
391
|
+
if (row > maxRow) maxRow = row;
|
|
392
|
+
const x = x0 + col * (cell + gap);
|
|
393
|
+
const y = gridTop + row * (cell + gap);
|
|
394
|
+
let color = heat.empty;
|
|
395
|
+
if (d.tokens > 0) {
|
|
396
|
+
const ratio = d.tokens / maxTok;
|
|
397
|
+
const idx = ratio >= 0.75 ? 4 : ratio >= 0.5 ? 3 : ratio >= 0.25 ? 2 : ratio >= 0.08 ? 1 : 0;
|
|
398
|
+
color = heat.scale[idx];
|
|
399
|
+
}
|
|
400
|
+
ctx.fillStyle = color;
|
|
401
|
+
_roundRect(ctx, x, y, cell, cell, 6);
|
|
402
|
+
ctx.fill();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return gridTop + (maxRow + 1) * (cell + gap) - gap;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Draws `text` centered at cx, fitting within (maxWidth × maxHeight) starting
|
|
409
|
+
// at startY. Picks the largest font (from a descending ladder) whose wrapped
|
|
410
|
+
// lines fit the height, honoring explicit "\n" breaks. Vertically centers.
|
|
411
|
+
function _drawAutoText(ctx, text, cx, startY, maxWidth, maxHeight) {
|
|
412
|
+
const sizes = [40, 36, 32, 28, 24, 20];
|
|
413
|
+
let chosen = null;
|
|
414
|
+
for (const size of sizes) {
|
|
415
|
+
ctx.font = "500 " + size + "px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
416
|
+
const lh = Math.round(size * 1.4);
|
|
417
|
+
const lines = _wrapLines(ctx, text, maxWidth);
|
|
418
|
+
if (lines.length * lh <= maxHeight || size === sizes[sizes.length - 1]) {
|
|
419
|
+
chosen = { size, lh, lines };
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
ctx.font = "500 " + chosen.size + "px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
424
|
+
const totalH = chosen.lines.length * chosen.lh;
|
|
425
|
+
let y = startY + (maxHeight - totalH) / 2 + chosen.lh * 0.75;
|
|
426
|
+
for (const line of chosen.lines) {
|
|
427
|
+
ctx.fillText(line, cx, y);
|
|
428
|
+
y += chosen.lh;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Wraps text into lines fitting maxWidth, honoring explicit "\n" breaks.
|
|
433
|
+
function _wrapLines(ctx, text, maxWidth) {
|
|
434
|
+
const out = [];
|
|
435
|
+
for (const para of String(text).split("\n")) {
|
|
436
|
+
let line = "";
|
|
437
|
+
for (const ch of para) {
|
|
438
|
+
if (ctx.measureText(line + ch).width > maxWidth && line) {
|
|
439
|
+
out.push(line);
|
|
440
|
+
line = ch;
|
|
441
|
+
} else {
|
|
442
|
+
line += ch;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
out.push(line);
|
|
446
|
+
}
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function _roundRect(ctx, x, y, w, h, r) {
|
|
451
|
+
ctx.beginPath();
|
|
452
|
+
ctx.moveTo(x + r, y);
|
|
453
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
454
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
455
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
456
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
457
|
+
ctx.closePath();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Paints a richer-than-flat poster background: base diagonal gradient, soft
|
|
461
|
+
// radial color glows, and a faint grid. Deterministic (no randomness) so the
|
|
462
|
+
// poster is reproducible and white-label safe.
|
|
463
|
+
function _paintBackground(ctx, W, H, t, base) {
|
|
464
|
+
const baseGrad = ctx.createLinearGradient(0, 0, W, H);
|
|
465
|
+
baseGrad.addColorStop(0, base[0]);
|
|
466
|
+
baseGrad.addColorStop(1, base[1]);
|
|
467
|
+
ctx.fillStyle = baseGrad;
|
|
468
|
+
ctx.fillRect(0, 0, W, H);
|
|
469
|
+
|
|
470
|
+
const diag = Math.sqrt(W * W + H * H);
|
|
471
|
+
(t.glows || []).forEach((g) => {
|
|
472
|
+
const cx = g.x * W, cy = g.y * H, radius = g.r * diag * 0.5;
|
|
473
|
+
const rg = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
|
474
|
+
rg.addColorStop(0, g.color);
|
|
475
|
+
rg.addColorStop(1, "rgba(0,0,0,0)");
|
|
476
|
+
ctx.fillStyle = rg;
|
|
477
|
+
ctx.fillRect(0, 0, W, H);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
if (t.grid) {
|
|
481
|
+
ctx.strokeStyle = t.grid;
|
|
482
|
+
ctx.lineWidth = 1;
|
|
483
|
+
const step = 48;
|
|
484
|
+
ctx.beginPath();
|
|
485
|
+
for (let x = step; x < W; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
|
|
486
|
+
for (let y = step; y < H; y += step) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
|
|
487
|
+
ctx.stroke();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Subtle top sheen for depth.
|
|
491
|
+
const sheen = ctx.createLinearGradient(0, 0, 0, H * 0.35);
|
|
492
|
+
sheen.addColorStop(0, t.style === "geek" ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.18)");
|
|
493
|
+
sheen.addColorStop(1, "rgba(255,255,255,0)");
|
|
494
|
+
ctx.fillStyle = sheen;
|
|
495
|
+
ctx.fillRect(0, 0, W, H * 0.35);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Platform actions ──────────────────────────────────────────────────
|
|
499
|
+
function _copy(str) {
|
|
500
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
501
|
+
navigator.clipboard.writeText(str).then(
|
|
502
|
+
() => Modal.toast(I18n.t("share.copied"), "success"),
|
|
503
|
+
() => _copyFallback(str)
|
|
504
|
+
);
|
|
505
|
+
} else {
|
|
506
|
+
_copyFallback(str);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function _copyFallback(str) {
|
|
511
|
+
const ta = document.createElement("textarea");
|
|
512
|
+
ta.value = str;
|
|
513
|
+
ta.style.position = "fixed";
|
|
514
|
+
ta.style.opacity = "0";
|
|
515
|
+
document.body.appendChild(ta);
|
|
516
|
+
ta.select();
|
|
517
|
+
try { document.execCommand("copy"); Modal.toast(I18n.t("share.copied"), "success"); }
|
|
518
|
+
catch (_e) { Modal.toast(I18n.t("share.copyFailed"), "error"); }
|
|
519
|
+
finally { ta.remove(); }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Append the landing URL (when present) to a body of share text.
|
|
523
|
+
function _withUrl(body) {
|
|
524
|
+
const url = _shareUrl();
|
|
525
|
+
return url ? `${body} ${url}`.trim() : body;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function _toWeibo(text) {
|
|
529
|
+
const url = _shareUrl() || "";
|
|
530
|
+
const share = "https://service.weibo.com/share/share.php?url=" +
|
|
531
|
+
encodeURIComponent(url) + "&title=" + encodeURIComponent(text);
|
|
532
|
+
window.open(share, "_blank", "noopener,noreferrer");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ── Poster sharing helpers ────────────────────────────────────────────
|
|
536
|
+
function _posterFilename() {
|
|
537
|
+
return `${_brand.name.toLowerCase()}-${_scorecard ? "scorecard" : "share"}.png`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function _downloadPoster(copy) {
|
|
541
|
+
const a = document.createElement("a");
|
|
542
|
+
a.href = _scorecard ? _buildScorecardPoster(copy) : _buildPoster(copy);
|
|
543
|
+
a.download = _posterFilename();
|
|
544
|
+
a.click();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function _saveDraft(text) {
|
|
548
|
+
try { localStorage.setItem(DRAFT_KEY, text); } catch (_e) { /* quota, ignore */ }
|
|
549
|
+
}
|
|
550
|
+
function _loadDraft() {
|
|
551
|
+
try { return localStorage.getItem(DRAFT_KEY) || ""; } catch (_e) { return ""; }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ── Modal UI ──────────────────────────────────────────────────────────
|
|
555
|
+
let _overlay = null;
|
|
556
|
+
let _activePlatform = "weibo";
|
|
557
|
+
|
|
558
|
+
const PLATFORMS = ["weibo", "xhs", "wechat", "bilibili"];
|
|
559
|
+
|
|
560
|
+
function _telemetry(event, extra) {
|
|
561
|
+
if (typeof fetch === "undefined") return;
|
|
562
|
+
const body = JSON.stringify({ event: event, extra: extra || {} });
|
|
563
|
+
fetch("/api/telemetry", { method: "POST", headers: { "Content-Type": "application/json" }, body: body }).catch(() => {});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function openModal() {
|
|
567
|
+
// Tear down a stale overlay without clearing scorecard state (closeModal
|
|
568
|
+
// nulls _scorecard, which openScorecard has just set).
|
|
569
|
+
if (_overlay) { _overlay.remove(); _overlay = null; }
|
|
570
|
+
|
|
571
|
+
const hasUrl = !!_shareUrl();
|
|
572
|
+
const titleKey = _scorecard ? "share.scorecard.modal.title" : "share.modal.title";
|
|
573
|
+
const subtitleKey = _scorecard ? "share.scorecard.modal.subtitle" : "share.modal.subtitle";
|
|
574
|
+
|
|
575
|
+
_activePlatform = "weibo";
|
|
576
|
+
|
|
577
|
+
const o = document.createElement("div");
|
|
578
|
+
o.className = "share-overlay";
|
|
579
|
+
|
|
580
|
+
const themeChips = THEME_ORDER.map((id) => {
|
|
581
|
+
const th = THEMES[id];
|
|
582
|
+
const on = id === _themeId() ? " is-active" : "";
|
|
583
|
+
return '<button type="button" class="share-theme-chip' + on + '" data-theme="' + id + '"' +
|
|
584
|
+
' style="background:' + th.swatch + '" title="' + _esc(I18n.t(th.labelKey)) + '">' +
|
|
585
|
+
'<span class="share-theme-name">' + _esc(I18n.t(th.labelKey)) + '</span></button>';
|
|
586
|
+
}).join("");
|
|
587
|
+
|
|
588
|
+
const platformTabs = PLATFORMS.map((p) => {
|
|
589
|
+
const on = p === _activePlatform ? " is-active" : "";
|
|
590
|
+
return '<button type="button" class="share-platform' + on + '" data-platform="' + p + '">' +
|
|
591
|
+
_esc(I18n.t("share.platform." + p)) + '</button>';
|
|
592
|
+
}).join("");
|
|
593
|
+
|
|
594
|
+
// Scorecard period switcher slot (filled by renderPeriodTabs below; can be
|
|
595
|
+
// re-rendered when billing hot-swaps in more periods asynchronously).
|
|
596
|
+
|
|
597
|
+
o.innerHTML =
|
|
598
|
+
'<div class="share-modal" role="dialog" aria-modal="true">' +
|
|
599
|
+
'<button type="button" class="share-close" aria-label="Close">×</button>' +
|
|
600
|
+
'<h3 class="share-title">' + _esc(I18n.t(titleKey, { brand: _brand.name })) + '</h3>' +
|
|
601
|
+
'<p class="share-subtitle">' + _esc(I18n.t(subtitleKey)) + '</p>' +
|
|
602
|
+
'<div class="share-body">' +
|
|
603
|
+
'<div class="share-poster-wrap"><img class="share-poster-img" alt="poster"/></div>' +
|
|
604
|
+
'<div class="share-controls">' +
|
|
605
|
+
'<div class="share-theme-row">' +
|
|
606
|
+
'<span class="share-row-label">' + _esc(I18n.t("share.theme.label")) + '</span>' +
|
|
607
|
+
'<div class="share-theme-chips">' + themeChips + '</div>' +
|
|
608
|
+
'</div>' +
|
|
609
|
+
'<div class="share-periods-slot"></div>' +
|
|
610
|
+
'<div class="share-platforms">' + platformTabs + '</div>' +
|
|
611
|
+
'<div class="share-editor">' +
|
|
612
|
+
'<div class="share-editor-head">' +
|
|
613
|
+
'<span class="share-row-label">' + _esc(I18n.t("share.editor.label")) + '</span>' +
|
|
614
|
+
'<button type="button" class="share-shuffle" data-act="shuffle">🎲 ' + _esc(I18n.t("share.action.shuffle")) + '</button>' +
|
|
615
|
+
'</div>' +
|
|
616
|
+
'<textarea class="share-text" rows="4"></textarea>' +
|
|
617
|
+
'</div>' +
|
|
618
|
+
'<div class="share-actions">' +
|
|
619
|
+
'<button type="button" class="share-btn-primary" data-act="primary"></button>' +
|
|
620
|
+
'<button type="button" class="share-btn-secondary" data-act="copytext">' + _esc(I18n.t("share.action.copyText")) + '</button>' +
|
|
621
|
+
'<button type="button" class="share-btn-secondary" data-act="download">' + _esc(I18n.t("share.action.download")) + '</button>' +
|
|
622
|
+
(hasUrl ? '<button type="button" class="share-btn-secondary" data-act="copylink">' + _esc(I18n.t("share.action.copyLink")) + '</button>' : '') +
|
|
623
|
+
'</div>' +
|
|
624
|
+
'</div>' +
|
|
625
|
+
'</div>' +
|
|
626
|
+
'</div>';
|
|
627
|
+
|
|
628
|
+
document.body.appendChild(o);
|
|
629
|
+
_overlay = o;
|
|
630
|
+
|
|
631
|
+
const img = o.querySelector(".share-poster-img");
|
|
632
|
+
const textarea = o.querySelector(".share-text");
|
|
633
|
+
|
|
634
|
+
const renderPoster = () => {
|
|
635
|
+
const copy = (textarea.value || "").trim();
|
|
636
|
+
try { img.src = _scorecard ? _buildScorecardPoster(copy) : _buildPoster(copy); }
|
|
637
|
+
catch (_e) { o.querySelector(".share-poster-wrap").style.display = "none"; }
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// Seed the editor: prefer the user's last hand-edited draft (product mode
|
|
641
|
+
// only — scorecard text carries live numbers and shouldn't be stale).
|
|
642
|
+
const draft = _scorecard ? "" : _loadDraft();
|
|
643
|
+
textarea.value = draft || _pickCopy(_activePlatform);
|
|
644
|
+
renderPoster();
|
|
645
|
+
|
|
646
|
+
textarea.addEventListener("input", () => {
|
|
647
|
+
if (!_scorecard) _saveDraft(textarea.value);
|
|
648
|
+
renderPoster();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
const setActivePlatform = (p) => {
|
|
652
|
+
_activePlatform = p;
|
|
653
|
+
o.querySelectorAll(".share-platform").forEach((b) => {
|
|
654
|
+
b.classList.toggle("is-active", b.getAttribute("data-platform") === p);
|
|
655
|
+
});
|
|
656
|
+
textarea.value = _pickCopy(p);
|
|
657
|
+
if (!_scorecard) _saveDraft(textarea.value);
|
|
658
|
+
_updatePrimaryLabel(o);
|
|
659
|
+
renderPoster();
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
o.querySelectorAll(".share-platform").forEach((b) => {
|
|
663
|
+
b.onclick = () => setActivePlatform(b.getAttribute("data-platform"));
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
o.querySelectorAll(".share-theme-chip").forEach((chip) => {
|
|
667
|
+
chip.onclick = () => {
|
|
668
|
+
_setTheme(chip.getAttribute("data-theme"));
|
|
669
|
+
o.querySelectorAll(".share-theme-chip").forEach((c) => {
|
|
670
|
+
c.classList.toggle("is-active", c === chip);
|
|
671
|
+
});
|
|
672
|
+
renderPoster();
|
|
673
|
+
};
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const renderPeriodTabs = () => {
|
|
677
|
+
const slot = o.querySelector(".share-periods-slot");
|
|
678
|
+
if (!slot) return;
|
|
679
|
+
const order = ["day", "week", "month"];
|
|
680
|
+
const avail = _scorecard && _scorecard.periods ? _scorecard.periods : {};
|
|
681
|
+
const keys = order.filter((p) => avail[p]);
|
|
682
|
+
if (keys.length <= 1) { slot.innerHTML = ""; return; }
|
|
683
|
+
slot.innerHTML = '<div class="share-periods">' + keys.map((p) => {
|
|
684
|
+
const on = p === _scorePeriod ? " is-active" : "";
|
|
685
|
+
return '<button type="button" class="share-period' + on + '" data-period="' + p + '">' +
|
|
686
|
+
_esc(I18n.t("share.scorecard.period." + p)) + '</button>';
|
|
687
|
+
}).join("") + '</div>';
|
|
688
|
+
slot.querySelectorAll(".share-period").forEach((b) => {
|
|
689
|
+
b.onclick = () => {
|
|
690
|
+
_scorePeriod = b.getAttribute("data-period");
|
|
691
|
+
slot.querySelectorAll(".share-period").forEach((x) => {
|
|
692
|
+
x.classList.toggle("is-active", x === b);
|
|
693
|
+
});
|
|
694
|
+
textarea.value = _pickCopy(_activePlatform);
|
|
695
|
+
renderPoster();
|
|
696
|
+
};
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
renderPeriodTabs();
|
|
700
|
+
_rerenderPeriods = renderPeriodTabs;
|
|
701
|
+
|
|
702
|
+
const close = () => closeModal();
|
|
703
|
+
o.querySelector(".share-close").onclick = close;
|
|
704
|
+
o.addEventListener("click", (e) => { if (e.target === o) close(); });
|
|
705
|
+
|
|
706
|
+
o.querySelectorAll("[data-act]").forEach((btn) => {
|
|
707
|
+
const act = btn.getAttribute("data-act");
|
|
708
|
+
if (act === "shuffle") {
|
|
709
|
+
btn.onclick = () => {
|
|
710
|
+
textarea.value = _pickCopy(_activePlatform, textarea.value.trim());
|
|
711
|
+
if (!_scorecard) _saveDraft(textarea.value);
|
|
712
|
+
renderPoster();
|
|
713
|
+
};
|
|
714
|
+
} else {
|
|
715
|
+
btn.onclick = () => _handleAction(act, textarea);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
_updatePrimaryLabel(o);
|
|
720
|
+
_telemetry("share_open", { type: _scorecard ? "scorecard" : "share" });
|
|
721
|
+
requestAnimationFrame(() => o.classList.add("open"));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Primary button label/behavior depends on the active platform: Weibo gets
|
|
725
|
+
// a real one-click jump; image-first platforms (xhs/wechat/bilibili) get
|
|
726
|
+
// "download poster + copy text".
|
|
727
|
+
function _updatePrimaryLabel(o) {
|
|
728
|
+
const btn = o.querySelector('[data-act="primary"]');
|
|
729
|
+
if (!btn) return;
|
|
730
|
+
btn.textContent = _activePlatform === "weibo"
|
|
731
|
+
? I18n.t("share.action.toWeibo")
|
|
732
|
+
: I18n.t("share.action.downloadAndCopy");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function _handleAction(act, textarea) {
|
|
736
|
+
const text = (textarea && textarea.value || "").trim();
|
|
737
|
+
switch (act) {
|
|
738
|
+
case "copytext":
|
|
739
|
+
_copy(_withUrl(text));
|
|
740
|
+
break;
|
|
741
|
+
case "download":
|
|
742
|
+
_downloadPoster(text);
|
|
743
|
+
_telemetry("share_download", { platform: _activePlatform, type: _scorecard ? "scorecard" : "share" });
|
|
744
|
+
break;
|
|
745
|
+
case "copylink":
|
|
746
|
+
_copy(_shareUrl());
|
|
747
|
+
break;
|
|
748
|
+
case "primary":
|
|
749
|
+
_primaryShare(text);
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function _primaryShare(text) {
|
|
755
|
+
switch (_activePlatform) {
|
|
756
|
+
case "weibo":
|
|
757
|
+
_toWeibo(text);
|
|
758
|
+
break;
|
|
759
|
+
default:
|
|
760
|
+
_downloadPoster(text);
|
|
761
|
+
_copy(_withUrl(text));
|
|
762
|
+
_telemetry("share_download", { platform: _activePlatform, type: _scorecard ? "scorecard" : "share" });
|
|
763
|
+
Modal.toast(I18n.t("share.hint." + _activePlatform), "info");
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function closeModal() {
|
|
769
|
+
if (!_overlay) return;
|
|
770
|
+
const o = _overlay;
|
|
771
|
+
_overlay = null;
|
|
772
|
+
_scorecard = null;
|
|
773
|
+
_scorePeriod = null;
|
|
774
|
+
_rerenderPeriods = null;
|
|
775
|
+
o.classList.remove("open");
|
|
776
|
+
setTimeout(() => o.remove(), 200);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ── Scorecard entry (B-line) ──────────────────────────────────────────
|
|
780
|
+
// Opens the share modal in "scorecard" mode with live billing numbers.
|
|
781
|
+
// stats: { period, cacheHitRate, costStr, tokensStr, requests }
|
|
782
|
+
function openScorecard(stats) {
|
|
783
|
+
_scorecard = stats || null;
|
|
784
|
+
_scorePeriod = _scorecard
|
|
785
|
+
? (_scorecard.defaultPeriod && _scorecard.periods && _scorecard.periods[_scorecard.defaultPeriod]
|
|
786
|
+
? _scorecard.defaultPeriod
|
|
787
|
+
: (_scorecard.periods ? Object.keys(_scorecard.periods)[0] : null))
|
|
788
|
+
: null;
|
|
789
|
+
openModal();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Called by billing when a period's numbers arrive after the modal already
|
|
793
|
+
// opened (instant-open path). Merges them and re-renders the switcher.
|
|
794
|
+
function addScorecardPeriod(key, stats) {
|
|
795
|
+
if (!_scorecard || !_overlay) return;
|
|
796
|
+
if (!_scorecard.periods) _scorecard.periods = {};
|
|
797
|
+
_scorecard.periods[key] = stats;
|
|
798
|
+
if (_rerenderPeriods) _rerenderPeriods();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ── Auto-prompt at peak-delight moments ───────────────────────────────
|
|
802
|
+
// Called from ws-dispatcher on a successful `complete` event (the active
|
|
803
|
+
// session the user is watching — onboarding runs in its own panel, never
|
|
804
|
+
// emitting this for the chat view). Shows a gentle toast, not a modal,
|
|
805
|
+
// so we never interrupt the moment.
|
|
806
|
+
function maybePromptOnComplete() {
|
|
807
|
+
const n = _bumpSuccessCount();
|
|
808
|
+
if (_inCooldown()) return;
|
|
809
|
+
if (!PROMPT_AT.includes(n)) return;
|
|
810
|
+
|
|
811
|
+
Modal.toast(I18n.t("share.prompt.message", { brand: _brand.name }), "success", {
|
|
812
|
+
duration: 8000,
|
|
813
|
+
action: {
|
|
814
|
+
label: I18n.t("share.prompt.action"),
|
|
815
|
+
onClick: openModal
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
// Any prompt (taken or ignored) starts the cooldown so we stay quiet
|
|
819
|
+
// for the next 7 days regardless.
|
|
820
|
+
_startCooldown();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function _esc(s) {
|
|
824
|
+
return String(s ?? "")
|
|
825
|
+
.replace(/&/g, "&").replace(/</g, "<")
|
|
826
|
+
.replace(/>/g, ">").replace(/"/g, """);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ── Init ──────────────────────────────────────────────────────────────
|
|
830
|
+
function init() {
|
|
831
|
+
_hydrateBrand();
|
|
832
|
+
const btn = document.getElementById("share-toggle-header");
|
|
833
|
+
if (btn) btn.addEventListener("click", openModal);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return { init, openModal, openScorecard, addScorecardPeriod, closeModal, maybePromptOnComplete };
|
|
837
|
+
})();
|
|
838
|
+
|
|
839
|
+
if (document.readyState === "loading") {
|
|
840
|
+
document.addEventListener("DOMContentLoaded", () => Share.init());
|
|
841
|
+
} else {
|
|
842
|
+
Share.init();
|
|
843
|
+
}
|