@songsid/agend 2.3.0-beta.21 → 2.3.0-beta.22
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/dist/agent-endpoint.d.ts +8 -0
- package/dist/agent-endpoint.js +11 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/auth-api.js +2 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/grok.js +56 -3
- package/dist/backend/grok.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/config-validator.js +8 -1
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +14 -2
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +6 -0
- package/dist/daemon.js +14 -0
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +28 -2
- package/dist/fleet-manager.js +102 -15
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +3 -1
- package/dist/instance-config-impact.js +3 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.js +2 -2
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.js +2 -1
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +19 -6
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.js +25 -10
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/provider-secret-registry.d.ts +5 -3
- package/dist/provider-secret-registry.js +8 -2
- package/dist/provider-secret-registry.js.map +1 -1
- package/dist/public-tunnel-config.d.ts +6 -0
- package/dist/public-tunnel-config.js +24 -0
- package/dist/public-tunnel-config.js.map +1 -0
- package/dist/public-web-gateway.d.ts +2 -0
- package/dist/public-web-gateway.js +3 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +13 -2
- package/dist/public-web-link.js +35 -9
- package/dist/public-web-link.js.map +1 -1
- package/dist/secret-store.d.ts +2 -0
- package/dist/secret-store.js +21 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/settings-api.d.ts +1 -0
- package/dist/settings-api.js +13 -9
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +2 -2
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +4 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +1 -1
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +6 -15
- package/dist/tunnel/cloudflared.js +20 -9
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/ui/app.html +2 -0
- package/dist/ui/chat-render.js +39 -0
- package/dist/ui/chat-store.js +8 -0
- package/dist/ui/chat-strings.js +2 -2
- package/dist/ui/chat-thread.js +1 -1
- package/dist/ui/fleet-canvas.js +14 -6
- package/dist/ui/fleet-route-scene.js +86 -21
- package/dist/ui/fleet-traffic.js +57 -10
- package/dist/ui/icons/icon-192.png +0 -0
- package/dist/ui/icons/icon-512.png +0 -0
- package/dist/ui/panel-settings.js +57 -4
- package/dist/ui/settings-appearance.js +126 -1
- package/dist/ui/settings-learn.js +1 -0
- package/dist/ui/settings-strings.js +78 -0
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +47 -21
- package/dist/ui/signin.html +2 -0
- package/dist/web-api.d.ts +6 -2
- package/dist/web-api.js +114 -4
- package/dist/web-api.js.map +1 -1
- package/dist/web-app-icon-worker.d.ts +1 -0
- package/dist/web-app-icon-worker.js +15 -0
- package/dist/web-app-icon-worker.js.map +1 -0
- package/dist/web-app-icon.d.ts +107 -0
- package/dist/web-app-icon.js +256 -0
- package/dist/web-app-icon.js.map +1 -0
- package/dist/web-chat-history.d.ts +23 -0
- package/dist/web-chat-history.js +32 -2
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-guest-access.js +7 -1
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-icons.d.ts +33 -1
- package/dist/web-icons.js +83 -11
- package/dist/web-icons.js.map +1 -1
- package/dist/web-png.d.ts +29 -0
- package/dist/web-png.js +321 -0
- package/dist/web-png.js.map +1 -0
- package/dist/web-reactions.d.ts +34 -0
- package/dist/web-reactions.js +47 -0
- package/dist/web-reactions.js.map +1 -0
- package/package.json +1 -1
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// the box's padding and the gutters between its rows and columns (L.pad, L.gap: wide enough for a few lines each)
|
|
8
8
|
// — are the only doors, so a line crosses a border at right angles, through a door, and never runs along it.
|
|
9
9
|
// - Ends: pins on each card's sides (several on the long sides, one on the short), facing out; a line ends on a card,
|
|
10
|
-
// never on a box. A card with more lines than pins gets more, so two lines never start at one point.
|
|
10
|
+
// never on a box. A card with more lines than pins gets more, so two lines never start at one point. A short side
|
|
11
|
+
// facing a neighbour across less than ROUTE.SIDE (a column gutter, a phone's box padding) has no pin (#2329): a line
|
|
12
|
+
// into it would turn in that narrow gap with no room left for its arrowhead. libavoid turns a line into its pin right
|
|
13
|
+
// at the card's buffer, which leaves a head no straight run: lengthen() moves that turn out where there is room.
|
|
11
14
|
// - Separation: libavoid nudges lines that share a run ROUTE.NUDGE apart; the same two ends and kind are one line (×N)
|
|
12
15
|
// before they get here (fleet-traffic.js).
|
|
13
16
|
// A route that comes back otherwise (not orthogonal, not ending on its two shapes) is not drawn: that line keeps the
|
|
@@ -15,10 +18,13 @@
|
|
|
15
18
|
import { OC } from "./fleet-canvas-layout.js";
|
|
16
19
|
|
|
17
20
|
/** Buffer around every obstacle, the distance nudging keeps between lines, the cost of a bend, pins on a long side. */
|
|
18
|
-
export const ROUTE = { BUF: 5, NUDGE: 8, SEG: 60, PINS: 4, MAX_PINS: 64, MAX_POINTS: 64, MAX_SHAPES: 600, MAX_CONNS: 120 };
|
|
21
|
+
export const ROUTE = { BUF: 5, NUDGE: 8, SEG: 60, PINS: 4, MAX_PINS: 64, MAX_POINTS: 64, MAX_SHAPES: 600, MAX_CONNS: 120, SIDE: 30, RUN: 16 };
|
|
19
22
|
/** libavoid's ConnDirFlags: the way a line leaves a pin. */
|
|
20
23
|
const UP = 1, DOWN = 2, LEFT = 4, RIGHT = 8;
|
|
21
|
-
|
|
24
|
+
/** A shape's `sides`: which short sides have a pin (absent: both). */
|
|
25
|
+
const SIDE_L = 1, SIDE_R = 2;
|
|
26
|
+
const sidesOf = (sh) => sh.sides ?? SIDE_L | SIDE_R;
|
|
27
|
+
const pinsOn = (sh) => ROUTE.PINS * 2 + ((sidesOf(sh) & SIDE_L) ? 1 : 0) + ((sidesOf(sh) & SIDE_R) ? 1 : 0);
|
|
22
28
|
|
|
23
29
|
/** The shape a line's end is on: a card (General's or a member's) or a folded team's box. Null: not routed here. */
|
|
24
30
|
function endShape(end) {
|
|
@@ -30,8 +36,9 @@ function endShape(end) {
|
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* The scene for layout `L` and the lines `lines` ([{ key, a, z }], a and z ends as fleet-traffic.js endOf gives them):
|
|
33
|
-
* { shapes: [{ x, y, w, h, pins }], conns: [{ key, a, z }], boxes: [{ x, y, w, h }] } — `pins` 0 none (an
|
|
34
|
-
* only), 1 the usual pins, more: that many on each long side (a busy card);
|
|
39
|
+
* { shapes: [{ x, y, w, h, pins, sides? }], conns: [{ key, a, z }], boxes: [{ x, y, w, h }] } — `pins` 0 none (an
|
|
40
|
+
* obstacle only), 1 the usual pins, more: that many on each long side (a busy card); `sides` the short sides with a pin
|
|
41
|
+
* (1 left, 2 right; absent: both); every pin takes one line; `a`/`z` index
|
|
35
42
|
* shapes; `boxes` the open teams' outlines (separate() keeps lines off them). A line whose end is not a card or a folded box is left out (it keeps its lane).
|
|
36
43
|
*/
|
|
37
44
|
export function routeScene(L, lines) {
|
|
@@ -57,9 +64,22 @@ export function routeScene(L, lines) {
|
|
|
57
64
|
conns.push({ key: ln.key, a: ia, z: iz });
|
|
58
65
|
for (const i of [ia, iz]) demand.set(i, (demand.get(i) || 0) + 1);
|
|
59
66
|
}
|
|
67
|
+
// A short side with a pin only where a line has room to turn into it with its head: ROUTE.SIDE to the nearest shape
|
|
68
|
+
// beside it (a neighbour card, a box's wall piece).
|
|
69
|
+
for (const sh of shapes) {
|
|
70
|
+
if (!sh.pins) continue;
|
|
71
|
+
let left = Infinity, right = Infinity;
|
|
72
|
+
for (const o of shapes) {
|
|
73
|
+
if (o === sh || o.y >= sh.y + sh.h || o.y + o.h <= sh.y) continue;
|
|
74
|
+
if (o.x + o.w <= sh.x) left = Math.min(left, sh.x - o.x - o.w);
|
|
75
|
+
if (o.x >= sh.x + sh.w) right = Math.min(right, o.x - sh.x - sh.w);
|
|
76
|
+
}
|
|
77
|
+
const sides = (left >= ROUTE.SIDE ? SIDE_L : 0) | (right >= ROUTE.SIDE ? SIDE_R : 0);
|
|
78
|
+
if (sides !== (SIDE_L | SIDE_R)) sh.sides = sides;
|
|
79
|
+
}
|
|
60
80
|
// A line each: a shape with more lines than pins gets more pins on its long sides (two lines never leave one point:
|
|
61
81
|
// libavoid cannot nudge apart runs that start at the same point).
|
|
62
|
-
for (const [i, n] of demand) if (n >
|
|
82
|
+
for (const [i, n] of demand) if (n > pinsOn(shapes[i])) shapes[i].pins = Math.min(ROUTE.MAX_PINS, Math.ceil((n - (pinsOn(shapes[i]) - ROUTE.PINS * 2)) / 2));
|
|
63
83
|
return { shapes, conns, boxes };
|
|
64
84
|
}
|
|
65
85
|
|
|
@@ -69,7 +89,8 @@ export function sceneOk(s) {
|
|
|
69
89
|
|| s.boxes.length > ROUTE.MAX_SHAPES) return null;
|
|
70
90
|
const num = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) < 1e6;
|
|
71
91
|
for (const b of s.boxes) if (!b || ![b.x, b.y, b.w, b.h].every(num) || b.w <= 0 || b.h <= 0) return null;
|
|
72
|
-
for (const sh of s.shapes) if (!sh || ![sh.x, sh.y, sh.w, sh.h].every(num) || sh.w <= 0 || sh.h <= 0 || !Number.isInteger(sh.pins) || sh.pins < 0 || sh.pins > ROUTE.MAX_PINS
|
|
92
|
+
for (const sh of s.shapes) if (!sh || ![sh.x, sh.y, sh.w, sh.h].every(num) || sh.w <= 0 || sh.h <= 0 || !Number.isInteger(sh.pins) || sh.pins < 0 || sh.pins > ROUTE.MAX_PINS
|
|
93
|
+
|| (sh.sides !== undefined && (!Number.isInteger(sh.sides) || sh.sides < 0 || sh.sides > (SIDE_L | SIDE_R)))) return null;
|
|
73
94
|
for (const c of s.conns) {
|
|
74
95
|
if (!c || typeof c.key !== "string" || c.key.length > 2048) return null;
|
|
75
96
|
for (const i of [c.a, c.z]) if (!Number.isInteger(i) || i < 0 || i >= s.shapes.length || !s.shapes[i].pins) return null;
|
|
@@ -97,15 +118,8 @@ export function answerRoute(A, m) {
|
|
|
97
118
|
return { type: "routes", id: m.id, routes: scene ? [...routeWith(A, scene)] : null };
|
|
98
119
|
}
|
|
99
120
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
102
|
-
* than ROUTE.NUDGE, and lines fanning out of one card can come back on the very same run. This pass moves the middle
|
|
103
|
-
* segments of such lines (never a line's first or last, which end on a pin) sideways to the nearest place at least
|
|
104
|
-
* SEPARATE from every parallel line along them — only where the moved segment and the two it joins stay clear of
|
|
105
|
-
* every shape's buffer and keep their direction. What cannot move stays as libavoid left it.
|
|
106
|
-
*/
|
|
107
|
-
export const SEPARATE = 6;
|
|
108
|
-
export function separate(routes, scene) {
|
|
121
|
+
/** What separate() and lengthen() share, on `scene` and the routes' points `pts` (which they move in place). */
|
|
122
|
+
function mover(scene, pts) {
|
|
109
123
|
const B = ROUTE.BUF - 0.5, OVER = 8;
|
|
110
124
|
const segsOf = (r, p) => p.slice(1).map((q, i) => ({ r, i, h: p[i].y === q.y }));
|
|
111
125
|
const coord = (p, s) => (s.h ? p[s.i].y : p[s.i].x);
|
|
@@ -120,9 +134,7 @@ export function separate(routes, scene) {
|
|
|
120
134
|
&& (scene.boxes ?? []).every((bx) => (h ? [bx.y, bx.y + bx.h].every((v) => Math.abs(v - y0) >= SEPARATE + 1) || Math.min(x1, bx.x + bx.w) - Math.max(x0, bx.x) <= 12
|
|
121
135
|
: [bx.x, bx.x + bx.w].every((v) => Math.abs(v - x0) >= SEPARATE + 1) || Math.min(y1, bx.y + bx.h) - Math.max(y0, bx.y) <= 12));
|
|
122
136
|
};
|
|
123
|
-
const keys = [...routes.keys()];
|
|
124
137
|
const connOf = new Map(scene.conns.map((c) => [c.key, c]));
|
|
125
|
-
const pts = keys.map((k) => routes.get(k).map((p) => ({ x: p.x, y: p.y })));
|
|
126
138
|
/** The segments of other lines (and this one's) parallel to segment s of line r, overlapping it, closer than SEPARATE. */
|
|
127
139
|
const near = (r, s, c, lo, hi) => {
|
|
128
140
|
const out = [];
|
|
@@ -131,8 +143,23 @@ export function separate(routes, scene) {
|
|
|
131
143
|
const [l2, h2] = span(p, t);
|
|
132
144
|
if (Math.min(hi, h2) - Math.max(lo, l2) > OVER) out.push(coord(p, t));
|
|
133
145
|
}));
|
|
134
|
-
return out.filter((c2) => Math.abs(c2 - c) < SEPARATE);
|
|
146
|
+
return out.filter((c2) => Math.abs(c2 - c) < SEPARATE - 1e-6); // libavoid's points carry float noise
|
|
135
147
|
};
|
|
148
|
+
return { segsOf, coord, span, clear, near, connOf };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* libavoid nudges lines that share a run apart, but not always: in a channel shared by many lines it packs them closer
|
|
153
|
+
* than ROUTE.NUDGE, and lines fanning out of one card can come back on the very same run. This pass moves the middle
|
|
154
|
+
* segments of such lines (never a line's first or last, which end on a pin) sideways to the nearest place at least
|
|
155
|
+
* SEPARATE from every parallel line along them — only where the moved segment and the two it joins stay clear of
|
|
156
|
+
* every shape's buffer and keep their direction. What cannot move stays as libavoid left it.
|
|
157
|
+
*/
|
|
158
|
+
export const SEPARATE = 6;
|
|
159
|
+
export function separate(routes, scene) {
|
|
160
|
+
const keys = [...routes.keys()];
|
|
161
|
+
const pts = keys.map((k) => routes.get(k).map((p) => ({ x: p.x, y: p.y })));
|
|
162
|
+
const { segsOf, coord, span, clear, near, connOf } = mover(scene, pts);
|
|
136
163
|
for (let pass = 0; pass < 8; pass++) {
|
|
137
164
|
let moved = false;
|
|
138
165
|
pts.forEach((p, r) => segsOf(r, p).forEach((s) => {
|
|
@@ -163,6 +190,43 @@ export function separate(routes, scene) {
|
|
|
163
190
|
return new Map(keys.map((k, i) => [k, pts[i]]));
|
|
164
191
|
}
|
|
165
192
|
|
|
193
|
+
/**
|
|
194
|
+
* #2329: room for each line's arrowhead. libavoid turns a line into its last pin right at the card's buffer, so the
|
|
195
|
+
* last run (where the head goes) is a few px long. This pass moves the turn before it out from the card until the last
|
|
196
|
+
* run is ROUTE.RUN long (or up to 3 × SEPARATE more; where none fits, the longest that does): only where the moved segment and the two it joins stay clear of
|
|
197
|
+
* every shape's buffer, at least SEPARATE from every parallel line, and the run before the turn keeps its direction and
|
|
198
|
+
* 4 px. A line whose turn cannot move keeps its short run (fleet-traffic.js draws it with no head).
|
|
199
|
+
*/
|
|
200
|
+
export function lengthen(routes, scene) {
|
|
201
|
+
const keys = [...routes.keys()];
|
|
202
|
+
const pts = keys.map((k) => routes.get(k).map((p) => ({ x: p.x, y: p.y })));
|
|
203
|
+
const { coord, span, clear, near, connOf } = mover(scene, pts);
|
|
204
|
+
pts.forEach((p, r) => {
|
|
205
|
+
const n = p.length;
|
|
206
|
+
if (n < 4) return; // the turn's other end is the first pin
|
|
207
|
+
const z = p[n - 1], y = p[n - 2], x = p[n - 3], w = p[n - 4], h = y.y === z.y;
|
|
208
|
+
const at = (q) => (h ? q.x : q.y);
|
|
209
|
+
const u = Math.sign(at(y) - at(z)); // out from the card
|
|
210
|
+
if (!u || Math.abs(at(y) - at(z)) >= ROUTE.RUN) return;
|
|
211
|
+
const c0 = connOf.get(keys[r]);
|
|
212
|
+
const run = Math.abs(at(y) - at(z)), tries = [];
|
|
213
|
+
for (let t = ROUTE.RUN; t <= ROUTE.RUN + 3 * SEPARATE; t++) tries.push(t);
|
|
214
|
+
for (let t = ROUTE.RUN - 1; t > run; t--) tries.push(t); // short of RUN (a phone's gutter): the longest that fits
|
|
215
|
+
for (const t of tries) {
|
|
216
|
+
const c2 = at(z) + u * t;
|
|
217
|
+
const a = h ? { x: c2, y: x.y } : { x: x.x, y: c2 }, b = h ? { x: c2, y: y.y } : { x: y.x, y: c2 };
|
|
218
|
+
if (Math.sign(at(a) - at(w)) !== Math.sign(at(x) - at(w)) || Math.abs(at(a) - at(w)) < 4) continue;
|
|
219
|
+
if (!clear(a, b) || !clear(w, a, n - 4 === 0 && c0 ? c0.a : -1) || !clear(b, z, c0 ? c0.z : -1)) continue;
|
|
220
|
+
const old = [p[n - 3], p[n - 2]];
|
|
221
|
+
p[n - 3] = a; p[n - 2] = b;
|
|
222
|
+
const ok = [n - 4, n - 3, n - 2].every((i) => { const s = { r, i, h: i === n - 3 ? !h : h }; const [lo, hi] = span(p, s); return !near(r, s, coord(p, s), lo, hi).length; });
|
|
223
|
+
if (ok) return;
|
|
224
|
+
[p[n - 3], p[n - 2]] = old;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
return new Map(keys.map((k, i) => [k, pts[i]]));
|
|
228
|
+
}
|
|
229
|
+
|
|
166
230
|
/**
|
|
167
231
|
* Route `scene` with libavoid instance `A` (libavoid-js 0.4.5's: its constants are numbers on `A`). A Map of
|
|
168
232
|
* connection key → points, for the routes that pass routeOk only.
|
|
@@ -186,7 +250,8 @@ export function routeWith(A, scene) {
|
|
|
186
250
|
const k = s.pins === 1 ? ROUTE.PINS : s.pins;
|
|
187
251
|
const pin = (x, y, dir) => new A.ShapeConnectionPin(ref, 1, x, y, true, 0, dir).setExclusive(true);
|
|
188
252
|
for (let i = 1; i <= k; i++) { pin(i / (k + 1), 0, UP); pin(i / (k + 1), 1, DOWN); }
|
|
189
|
-
|
|
253
|
+
if (sidesOf(s) & SIDE_L) pin(0, 0.5, LEFT);
|
|
254
|
+
if (sidesOf(s) & SIDE_R) pin(1, 0.5, RIGHT);
|
|
190
255
|
}
|
|
191
256
|
return ref;
|
|
192
257
|
});
|
|
@@ -200,7 +265,7 @@ export function routeWith(A, scene) {
|
|
|
200
265
|
const ok = routeOk(pts, scene, c);
|
|
201
266
|
if (ok) out.set(c.key, ok);
|
|
202
267
|
});
|
|
203
|
-
return ROUTE.SEPARATE === false ? out : separate(out, scene);
|
|
268
|
+
return ROUTE.SEPARATE === false ? out : lengthen(separate(out, scene), scene);
|
|
204
269
|
} finally {
|
|
205
270
|
A.destroy(r);
|
|
206
271
|
}
|
package/dist/ui/fleet-traffic.js
CHANGED
|
@@ -178,8 +178,15 @@ function endOf(L, name) {
|
|
|
178
178
|
|
|
179
179
|
// ── Routing: lanes the layout keeps empty ──
|
|
180
180
|
|
|
181
|
-
/**
|
|
181
|
+
/**
|
|
182
|
+
* A side lane's offset from the middle of the box's padding: at least 6 px from the box's border and from its cards.
|
|
183
|
+
* Negative: toward the border, on either side (#2329) — the first lines (o < 0) leave the cards a long last run for a head.
|
|
184
|
+
*/
|
|
182
185
|
const laneOffset = (o, PAD = OC.PAD) => Math.max(-PAD / 2 + 6, Math.min(PAD / 2 - 6, o * 0.5));
|
|
186
|
+
const leftLaneOf = (b, io, PAD) => b.x + PAD / 2 + io;
|
|
187
|
+
const rightLaneOf = (b, io, PAD) => b.x + b.w - PAD / 2 - io;
|
|
188
|
+
/** Where a line meets a card's side, from the side's middle (#2329): its full offset, a head's width from its neighbours. */
|
|
189
|
+
const sideOffset = (o) => Math.max(-OC.NH / 2 + 7, Math.min(OC.NH / 2 - 7, o));
|
|
183
190
|
/** The layout's own box padding and gutter (a phone's are narrower). */
|
|
184
191
|
const spacing = (L) => ({ PAD: L.pad ?? OC.PAD, GAP: L.gap ?? OC.GAP });
|
|
185
192
|
|
|
@@ -199,14 +206,14 @@ function outward(L, end, o) {
|
|
|
199
206
|
const { NW, NH } = OC, { PAD, GAP } = spacing(L);
|
|
200
207
|
const card = end.c, col = end.i % b.cols, rowI = Math.floor(end.i / b.cols), lines = Math.ceil(b.cells.length / b.cols);
|
|
201
208
|
const io = laneOffset(o, PAD);
|
|
202
|
-
const leftLane = b
|
|
209
|
+
const leftLane = leftLaneOf(b, io, PAD), rightLane = rightLaneOf(b, io, PAD);
|
|
203
210
|
let pts;
|
|
204
211
|
if (col === 0 || col === b.cols - 1) {
|
|
205
|
-
const left = col === 0, lane = left ? leftLane : rightLane, cy = card.y + NH / 2 +
|
|
212
|
+
const left = col === 0, lane = left ? leftLane : rightLane, cy = card.y + NH / 2 + sideOffset(o);
|
|
206
213
|
pts = [{ x: left ? card.x : card.x + NW, y: cy }, { x: lane, y: cy }, { x: lane, y: b.y }];
|
|
207
214
|
} else {
|
|
208
215
|
// A middle column: down into the gutter below its row, across to the nearer side lane.
|
|
209
|
-
const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2
|
|
216
|
+
const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2 - io / 2, cx = card.x + NW / 2 + o;
|
|
210
217
|
const lane = cx - b.x < b.w / 2 ? leftLane : rightLane;
|
|
211
218
|
pts = [{ x: cx, y: card.y + NH }, { x: cx, y: gy }, { x: lane, y: gy }, { x: lane, y: b.y }];
|
|
212
219
|
}
|
|
@@ -224,10 +231,10 @@ function toLane(L, end, lane, o) {
|
|
|
224
231
|
const io = laneOffset(o, PAD);
|
|
225
232
|
const left = lane < b.x + b.w / 2;
|
|
226
233
|
if ((left && col === 0) || (!left && col === b.cols - 1)) {
|
|
227
|
-
const cy = card.y + NH / 2 +
|
|
234
|
+
const cy = card.y + NH / 2 + sideOffset(o);
|
|
228
235
|
return [{ x: left ? card.x : card.x + NW, y: cy }, { x: lane, y: cy }];
|
|
229
236
|
}
|
|
230
|
-
const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2
|
|
237
|
+
const gy = card.y + NH + (rowI === lines - 1 ? PAD : GAP) / 2 - io / 2, cx = card.x + NW / 2 + o;
|
|
231
238
|
return [{ x: cx, y: card.y + NH }, { x: cx, y: gy }, { x: lane, y: gy }];
|
|
232
239
|
}
|
|
233
240
|
/** Two cards in one open box: joined inside it, through the side lane nearer to them both (never out to the bus). */
|
|
@@ -235,7 +242,7 @@ function inBox(L, a, z, o) {
|
|
|
235
242
|
const b = a.b, { PAD } = spacing(L);
|
|
236
243
|
const io = laneOffset(o, PAD);
|
|
237
244
|
const mid = ((a.i % b.cols) + (z.i % b.cols)) / 2;
|
|
238
|
-
const lane = mid <= (b.cols - 1) / 2 ? b
|
|
245
|
+
const lane = mid <= (b.cols - 1) / 2 ? leftLaneOf(b, io, PAD) : rightLaneOf(b, io, PAD);
|
|
239
246
|
return [...toLane(L, a, lane, o), ...toLane(L, z, lane, o).reverse()];
|
|
240
247
|
}
|
|
241
248
|
/** Two of General's cards: under them, through the gap above the first row (or the canvas's empty space below). */
|
|
@@ -280,6 +287,42 @@ export function badgeAt(pts) {
|
|
|
280
287
|
return best;
|
|
281
288
|
}
|
|
282
289
|
|
|
290
|
+
// ── Arrowheads (#2329) ──
|
|
291
|
+
|
|
292
|
+
/** A line's stroke width (px) by its kind and phase: the work's and its report's are heavier, a team pair's the most. */
|
|
293
|
+
export const strokeOf = (hue, phase) => (phase === "work" || phase === "return" ? 3 : hue === "team" ? 3.5 : 2.5);
|
|
294
|
+
/**
|
|
295
|
+
* The arrowhead and the ends. In stroke widths (the markers' units, fleet-canvas.js): a head LEN long and WIDE across,
|
|
296
|
+
* the line's own stroke ending BACK inside it (its round cap under the head, never out past the tip); the source dot
|
|
297
|
+
* DOT across. In px: the tip GAP short of the card, the dot's centre START off its card; a head only on a last straight
|
|
298
|
+
* run that holds it and ROOM of line before it — else the line ends GAP short with none (its dot and pulse show the way).
|
|
299
|
+
*/
|
|
300
|
+
export const HEAD = { LEN: 2.8, WIDE: 2.3, BACK: 1.4, DOT: 2, GAP: 3.5, START: 5, ROOM: 1 };
|
|
301
|
+
/** Whether a line of stroke `w` px whose last straight run is `run` px long gets a head. */
|
|
302
|
+
export const headFits = (run, w) => run >= HEAD.GAP + HEAD.LEN * w + HEAD.ROOM;
|
|
303
|
+
/** An orthogonal polyline without repeated points or points in the middle of a straight run. */
|
|
304
|
+
function corners(pts) {
|
|
305
|
+
const q = pts.filter((pt, i) => !i || pt.x !== pts[i - 1].x || pt.y !== pts[i - 1].y).map((pt) => ({ x: pt.x, y: pt.y }));
|
|
306
|
+
return q.filter((pt, i) => i === 0 || i === q.length - 1 || !((q[i - 1].x === pt.x && pt.x === q[i + 1].x) || (q[i - 1].y === pt.y && pt.y === q[i + 1].y)));
|
|
307
|
+
}
|
|
308
|
+
/** Route `pts` as drawn: its start moved `a` px along its first run, its end `b` px back along its last (each keeps 1 px). */
|
|
309
|
+
export function trimEnds(pts, a, b) {
|
|
310
|
+
const q = corners(pts);
|
|
311
|
+
if (q.length < 2) return q;
|
|
312
|
+
const pull = (i, j, by) => {
|
|
313
|
+
const l = Math.hypot(q[j].x - q[i].x, q[j].y - q[i].y), t = Math.max(0, Math.min(by, l - 1)) / l;
|
|
314
|
+
q[i] = { x: q[i].x + (q[j].x - q[i].x) * t, y: q[i].y + (q[j].y - q[i].y) * t };
|
|
315
|
+
};
|
|
316
|
+
pull(0, 1, a);
|
|
317
|
+
pull(q.length - 1, q.length - 2, b);
|
|
318
|
+
return q;
|
|
319
|
+
}
|
|
320
|
+
/** The length of a route's last straight run. */
|
|
321
|
+
export function lastRun(pts) {
|
|
322
|
+
const q = corners(pts);
|
|
323
|
+
return q.length < 2 ? 0 : Math.hypot(q[q.length - 1].x - q[q.length - 2].x, q[q.length - 1].y - q[q.length - 2].y);
|
|
324
|
+
}
|
|
325
|
+
|
|
283
326
|
// ── Edges ──
|
|
284
327
|
|
|
285
328
|
/** The colour family of a kind: task, report, broadcast, and the rest (send, query, update). */
|
|
@@ -291,8 +334,10 @@ const settled = (phase) => (phase === "fading" || phase === "stale" ? "fading" :
|
|
|
291
334
|
|
|
292
335
|
/**
|
|
293
336
|
* The lines to draw from the store's items on layout `L`. `far`: zoomed out (one line per team pair). Each edge:
|
|
294
|
-
* { key, hue, phase, n, from, to, d, pts, badge, a, z, routed } — `from`/`to` the instance (or team) names it
|
|
295
|
-
* search; `
|
|
337
|
+
* { key, hue, phase, n, from, to, d, pts, w, head, badge, a, z, routed } — `from`/`to` the instance (or team) names it
|
|
338
|
+
* joins, for search; `pts` its route, end to end (on the cards); `d` the path drawn, its ends trimmed off the cards for
|
|
339
|
+
* the dot and the head (#2329); `w` its stroke width; `head` whether it has a head (headFits);
|
|
340
|
+
* `a`/`z` its two ends (fleet-route-scene.js routes the ones on cards). `routed` (#2182): the router's points by
|
|
296
341
|
* line key (fleet-route.js); a line it has none for keeps its lane route — or, while a new answer is on its way, the
|
|
297
342
|
* points the same two ends and kind had in the last one (a line changing phase keeps its place).
|
|
298
343
|
*/
|
|
@@ -339,7 +384,9 @@ export function trafficEdges(items, L, { far = false, max = MAX_EDGES, routed =
|
|
|
339
384
|
const o = ((i % 7) - 3) * 7; // neighbours 7 px apart (within the ±22 px bus lane)
|
|
340
385
|
const viaRouter = routedBy(key);
|
|
341
386
|
const pts = viaRouter ?? route(L, g.a, g.z, o);
|
|
342
|
-
|
|
387
|
+
const w = strokeOf(g.hue, g.phase), head = headFits(lastRun(pts), w);
|
|
388
|
+
const d = pathOf(trimEnds(pts, HEAD.START, HEAD.GAP + (head ? HEAD.BACK * w : 0)));
|
|
389
|
+
return { key, hue: g.hue, phase: g.phase, n: g.n, names: [...new Set(g.names)], d, pts, w, head, badge: g.n > 1 ? badgeAt(pts) : null,
|
|
343
390
|
a: g.a, z: g.z, routed: !!viaRouter };
|
|
344
391
|
});
|
|
345
392
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -813,13 +813,13 @@ function WebSection({ ctx, staged }) {
|
|
|
813
813
|
echo: web.echo_to_channel ?? true,
|
|
814
814
|
log: web.log_tab === true, // #1218: off by default
|
|
815
815
|
usagePub: web.usage_report_on_public_link === true, // #2122: off by default
|
|
816
|
-
pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2" },
|
|
816
|
+
pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2", hostname: (web.public_link && web.public_link.hostname) ?? "", tunnel_token_env: (web.public_link && web.public_link.tunnel_token_env) ?? "", origin_port: (web.public_link && web.public_link.origin_port) ?? 19282 },
|
|
817
817
|
session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
|
|
818
818
|
terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
|
|
819
819
|
}), []);
|
|
820
820
|
const text = (v) => (v === null || v === undefined ? "" : String(v));
|
|
821
821
|
const [f, setF] = useState(() => ({
|
|
822
|
-
echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
|
|
822
|
+
echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
|
|
823
823
|
abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours),
|
|
824
824
|
termOn: original.terminal.enabled !== false, termTtl: text(original.terminal.ttl_minutes),
|
|
825
825
|
}));
|
|
@@ -832,6 +832,8 @@ function WebSection({ ctx, staged }) {
|
|
|
832
832
|
// the store and the validator apply it — so only an idle time that is typed can exceed the session length.
|
|
833
833
|
const abs = value(f.abs) ?? 48, idle = value(f.idle) ?? Math.min(48, abs);
|
|
834
834
|
const errors = {
|
|
835
|
+
hostname: f.hostname && !/^(?:https:\/\/)?[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+\/?$/.test(f.hostname) ? "namedHostnameInvalid" : "",
|
|
836
|
+
originPort: !Number.isInteger(Number(f.originPort)) || Number(f.originPort) < 1024 || Number(f.originPort) > 65535 ? "namedPortInvalid" : "",
|
|
835
837
|
ttl: !Number.isInteger(Number(f.ttl)) || Number(f.ttl) < 1 || Number(f.ttl) > 480 ? "publicTtl" : "",
|
|
836
838
|
abs: hours(f.abs),
|
|
837
839
|
idle: hours(f.idle) || (!hours(f.abs) && idle > abs ? "sessionIdleOver" : ""),
|
|
@@ -840,7 +842,7 @@ function WebSection({ ctx, staged }) {
|
|
|
840
842
|
const ready = !overPublicLink && !Object.values(errors).some(Boolean);
|
|
841
843
|
const review = () => {
|
|
842
844
|
if (!ready) return;
|
|
843
|
-
const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: Number(f.ttl), protocol: f.protocol }, original.pub);
|
|
845
|
+
const publicPatch = changedFields({ allow_public: f.pub, ttl_minutes: Number(f.ttl), protocol: f.protocol, hostname: f.hostname, tunnel_token_env: f.hostname ? "AGEND_CLOUDFLARE_TUNNEL_TOKEN" : "", origin_port: Number(f.originPort) }, original.pub);
|
|
844
846
|
const sessionPatch = changedFields({ absolute_hours: value(f.abs), idle_hours: value(f.idle) }, original.session);
|
|
845
847
|
// The checkbox shows the default (on) for an unset key: only a real toggle is written; on again = back to the default.
|
|
846
848
|
const terminalPatch = changedFields({ enabled: f.termOn === (original.terminal.enabled !== false) ? original.terminal.enabled : f.termOn ? null : false,
|
|
@@ -902,13 +904,18 @@ function WebSection({ ctx, staged }) {
|
|
|
902
904
|
<p class="note">${tn("webUsagePublicHint")}</p>
|
|
903
905
|
<label class="check"><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
|
|
904
906
|
<p class="note">${tn("publicLinkHint")}</p>
|
|
907
|
+
<div class="grid2">
|
|
908
|
+
<div class="field"><label for="publicWebHostname">${tn("namedHostname")}</label><input id="publicWebHostname" type="text" value=${f.hostname} placeholder="agend.example.com" class=${errors.hostname ? "invalid" : ""} onInput=${(e) => set("hostname")(e.target.value)} /><${Feedback} error=${errors.hostname} /></div>
|
|
909
|
+
<div class="field"><label for="publicWebOriginPort">${tn("namedOriginPort")}</label><input id="publicWebOriginPort" type="number" min="1024" max="65535" value=${f.originPort} class=${errors.originPort ? "invalid" : ""} onInput=${(e) => set("originPort")(e.target.value)} /><${Feedback} error=${errors.originPort} /></div>
|
|
910
|
+
</div>
|
|
911
|
+
<p class="note">${tn("namedTunnelHint")} <a href="https://github.com/songsid/AgEnD/blob/main/docs/named-tunnel.md" target="_blank" rel="noopener noreferrer">${tn("namedTunnelSetup")}</a></p>
|
|
905
912
|
<div class="grid2">
|
|
906
913
|
<div class="field"><label for="publicWebTtl">${tn("publicTtl")}</label><input id="publicWebTtl" type="number" min="1" max="480" class=${errors.ttl ? "invalid" : ""} value=${f.ttl} onInput=${(e) => set("ttl")(e.target.value)} /><${Feedback} error=${errors.ttl} /></div>
|
|
907
914
|
<div class="field"><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
|
|
908
915
|
</div>
|
|
909
916
|
</section>
|
|
910
917
|
</fieldset>
|
|
911
|
-
${overPublicLink ? null : html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />`}
|
|
918
|
+
${overPublicLink ? null : html`<${PublicTunnelToken} /><${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />`}
|
|
912
919
|
<section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
|
|
913
920
|
<p class="note">${tn("webReadOnlyLead")}</p>
|
|
914
921
|
<dl class="s-web-ro">${WEB_READ_ONLY.map((k) => html`<div key=${k}><dt><code>${k}</code></dt><dd>${shown(k)}</dd></div>`)}</dl>
|
|
@@ -1033,3 +1040,49 @@ function HelpDialog({ onClose }) {
|
|
|
1033
1040
|
}
|
|
1034
1041
|
|
|
1035
1042
|
export { worstImpact, ACCESS_MODES };
|
|
1043
|
+
|
|
1044
|
+
function PublicTunnelToken() {
|
|
1045
|
+
const lease = useLease("public-tunnel-token");
|
|
1046
|
+
const [secret, setSecret] = useState("");
|
|
1047
|
+
const [present, setPresent] = useState(false);
|
|
1048
|
+
const [busy, setBusy] = useState(false);
|
|
1049
|
+
const inFlight = useRef(false);
|
|
1050
|
+
const [message, setMessage] = useState("");
|
|
1051
|
+
const base = "/api/settings/public-tunnel-token";
|
|
1052
|
+
const refresh = async () => {
|
|
1053
|
+
const result = await api(base);
|
|
1054
|
+
if (lease.current() && result.ok) setPresent(result.body?.[0]?.token_present === true);
|
|
1055
|
+
};
|
|
1056
|
+
useEffect(() => { refresh().catch(() => {}); }, []);
|
|
1057
|
+
const go = async (clear) => {
|
|
1058
|
+
if (inFlight.current || !clear && !secret) return;
|
|
1059
|
+
const value = secret;
|
|
1060
|
+
setSecret(""); inFlight.current = true; setBusy(true); setMessage("");
|
|
1061
|
+
const key = newKey("tunnel-token");
|
|
1062
|
+
try {
|
|
1063
|
+
const prepared = await api(`${base}/verify`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(clear ? { clear: true } : { secret: value }) });
|
|
1064
|
+
if (!lease.current()) return;
|
|
1065
|
+
if (!prepared.ok) throw Error("refused");
|
|
1066
|
+
const applied = await confirmedWrite(`${base}/apply`, { method: "POST", key, label: tn(clear ? "namedTokenClear" : "namedTokenSet"), body: { verification_id: prepared.body?.verification_id, idempotency_key: key } });
|
|
1067
|
+
let body = applied.body || {};
|
|
1068
|
+
for (let n = 0; applied.ok && body.job_id && body.result === "applying" && n < 60; n++) {
|
|
1069
|
+
await new Promise(r => setTimeout(r, 250));
|
|
1070
|
+
const status = await api(`${base}/apply/${encodeURIComponent(body.job_id)}`);
|
|
1071
|
+
if (status.ok) body = status.body || body;
|
|
1072
|
+
}
|
|
1073
|
+
if (lease.current()) {
|
|
1074
|
+
setMessage(tn(applied.ok && body.result === "reloaded" ? "namedTokenSaved" : body.result === "pending_confirmation" ? "awaitingConfirmation" : "applyFailed"));
|
|
1075
|
+
await refresh();
|
|
1076
|
+
}
|
|
1077
|
+
} catch { if (lease.current()) setMessage(tn("applyFailed")); }
|
|
1078
|
+
finally { inFlight.current = false; if (lease.current()) setBusy(false); }
|
|
1079
|
+
};
|
|
1080
|
+
return html`<section class="card"><h3>${tn("namedToken")}</h3>
|
|
1081
|
+
<p class="note">${tn("namedTokenHint")}</p>
|
|
1082
|
+
<span class="tag">${present ? tn("keyConfigured") : tn("keyNotConfigured")}</span>
|
|
1083
|
+
<input id="publicTunnelToken" type="password" autocomplete="new-password" aria-label=${tn("namedToken")} value=${secret} onInput=${e => setSecret(e.target.value)} />
|
|
1084
|
+
<button type="button" class="btn btn-sm" disabled=${busy || !secret} onClick=${() => go(false)}>${tn("namedTokenSet")}</button>
|
|
1085
|
+
<button type="button" class="btn btn-sm" disabled=${busy || !present} onClick=${() => go(true)}>${tn("namedTokenClear")}</button>
|
|
1086
|
+
${message ? html`<p class="note" role="status">${message}</p>` : null}
|
|
1087
|
+
</section>`;
|
|
1088
|
+
}
|
|
@@ -45,7 +45,7 @@ export function AppearanceSection() {
|
|
|
45
45
|
const publicLink = typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1";
|
|
46
46
|
if (publicLink) return html`<section class="card s-appearance"><h3>${tn("cssTitle")}</h3><p class="sub">${tn("cssPublicLink")}</p></section>`;
|
|
47
47
|
const group = (key) => html`<h2 class="s-ap-group">${tn(key)}</h2>`;
|
|
48
|
-
return html`${group("groupLook")}<${ThemesCard} /><${IdentityColorCard} /><${CardSizeCard} />
|
|
48
|
+
return html`${group("groupLook")}<${ThemesCard} /><${IdentityColorCard} /><${CardSizeCard} /><${AppIconCard} />
|
|
49
49
|
${group("groupLayout")}<${SidebarWidgetCard} />
|
|
50
50
|
${group("groupCss")}<${OwnerCssCard} /><${DeviceCard} />
|
|
51
51
|
${group("groupMods")}<${ModsCard} /><${EffectsCard} />
|
|
@@ -127,6 +127,131 @@ function CardSizeCard() {
|
|
|
127
127
|
</section>`;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* #2308: the image as one square PNG the host makes the icon sizes from — centre-cropped, drawn at 512 px if smaller
|
|
132
|
+
* (marked low-resolution), as it is up to 1024 px, else 1024. The browser decodes whatever it can show (PNG, JPG, WebP);
|
|
133
|
+
* the host takes only that PNG.
|
|
134
|
+
*/
|
|
135
|
+
export async function appIconPng(file) {
|
|
136
|
+
const bmp = await createImageBitmap(file);
|
|
137
|
+
try {
|
|
138
|
+
const side = Math.min(bmp.width, bmp.height);
|
|
139
|
+
const out = Math.min(1024, Math.max(512, side));
|
|
140
|
+
const canvas = document.createElement("canvas");
|
|
141
|
+
canvas.width = canvas.height = out;
|
|
142
|
+
const g = canvas.getContext("2d");
|
|
143
|
+
g.imageSmoothingQuality = "high";
|
|
144
|
+
g.drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2, side, side, 0, 0, out, out);
|
|
145
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
146
|
+
if (!blob) throw new Error("no PNG");
|
|
147
|
+
return { blob, low: side < 512 };
|
|
148
|
+
} finally {
|
|
149
|
+
if (bmp.close) bmp.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** #2308: this page's icon links follow a change at once (the next pages carry the version from the host). */
|
|
154
|
+
export function refreshAppIconLinks(version, custom) {
|
|
155
|
+
if (typeof document === "undefined") return;
|
|
156
|
+
const v = encodeURIComponent(version);
|
|
157
|
+
const head = document.head;
|
|
158
|
+
const links = [...head.querySelectorAll("link")];
|
|
159
|
+
const byPath = (path) => links.find((l) => (l.getAttribute("href") || "").split("?")[0] === path);
|
|
160
|
+
for (const [path, rel] of [["/favicon.ico", "icon"], ["/apple-touch-icon.png", "apple-touch-icon"], ["/manifest.webmanifest", "manifest"]]) {
|
|
161
|
+
const el = byPath(path);
|
|
162
|
+
if (el && el.getAttribute("rel") === rel) el.setAttribute("href", `${path}?v=${v}`);
|
|
163
|
+
}
|
|
164
|
+
const svg = byPath("/favicon.svg");
|
|
165
|
+
if (custom && svg) svg.remove();
|
|
166
|
+
else if (!custom && !svg) {
|
|
167
|
+
const link = document.createElement("link");
|
|
168
|
+
for (const [k, val] of [["rel", "icon"], ["href", `/favicon.svg?v=${v}`], ["type", "image/svg+xml"], ["sizes", "any"]]) link.setAttribute(k, val);
|
|
169
|
+
head.appendChild(link);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* #2308: the app icon — what a browser tab and a home screen show. One picture for every page (sign-in and the public
|
|
175
|
+
* link too); an admin changes it, everyone else sees it.
|
|
176
|
+
*/
|
|
177
|
+
function AppIconCard() {
|
|
178
|
+
const [state, setState] = useState(null);
|
|
179
|
+
const [canWrite, setCanWrite] = useState(false);
|
|
180
|
+
const [picked, setPicked] = useState(null); // { blob, url, low }
|
|
181
|
+
const [busy, setBusy] = useState(false);
|
|
182
|
+
const [error, setError] = useState("");
|
|
183
|
+
const file = useRef(null);
|
|
184
|
+
const load = async () => {
|
|
185
|
+
const r = await call("/ui/app-icon");
|
|
186
|
+
setState(r.ok && r.body && typeof r.body.version === "string" ? r.body : { failed: true });
|
|
187
|
+
};
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
load();
|
|
190
|
+
const auth = typeof window !== "undefined" ? window.AgendAuth : null;
|
|
191
|
+
if (auth && auth.canWrite) Promise.resolve(auth.canWrite()).then((v) => setCanWrite(v === true), () => {});
|
|
192
|
+
}, []);
|
|
193
|
+
useEffect(() => () => { if (picked) URL.revokeObjectURL(picked.url); }, [picked]);
|
|
194
|
+
const title = html`<${CardTitle} title=${tn("appIconTitle")} topic="app-icon" />`;
|
|
195
|
+
if (!state) return html`<section class="card s-appearance s-app-icon">${title}<p class="sub">…</p></section>`;
|
|
196
|
+
if (state.failed) return html`<section class="card s-appearance s-app-icon">${title}<p class="err">${tn("appIconLoadFailed")}</p></section>`;
|
|
197
|
+
|
|
198
|
+
const choose = async () => {
|
|
199
|
+
const f = file.current && file.current.files && file.current.files[0];
|
|
200
|
+
if (file.current) file.current.value = "";
|
|
201
|
+
if (!f) return;
|
|
202
|
+
setError("");
|
|
203
|
+
try {
|
|
204
|
+
const made = await appIconPng(f);
|
|
205
|
+
setPicked({ ...made, url: URL.createObjectURL(made.blob) });
|
|
206
|
+
} catch {
|
|
207
|
+
setError(tn("appIconNotAnImage"));
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const answered = (r, done) => {
|
|
211
|
+
setBusy(false);
|
|
212
|
+
if (!r.ok) {
|
|
213
|
+
const known = ["app_icon_too_large", "app_icon_not_png", "app_icon_unreadable", "app_icon_admin_only", "app_icon_needs_a_signed_in_person", "app_icon_busy", "app_icon_superseded", "app_icon_not_reset"];
|
|
214
|
+
setError(known.includes(r.body && r.body.error) ? tn(`appIconError_${r.body.error}`, (r.body && r.body.detail) || "") : tn("appIconError_other", (r.body && r.body.error) || String(r.status)));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
setState(r.body);
|
|
218
|
+
setPicked(null);
|
|
219
|
+
refreshAppIconLinks(r.body.version, r.body.custom);
|
|
220
|
+
toast(done);
|
|
221
|
+
};
|
|
222
|
+
const use = async () => {
|
|
223
|
+
setBusy(true); setError("");
|
|
224
|
+
answered(await call("/ui/app-icon", { method: "PUT", headers: { "Content-Type": "image/png" }, body: picked.blob }), tn("appIconSaved"));
|
|
225
|
+
};
|
|
226
|
+
const reset = async () => {
|
|
227
|
+
setBusy(true); setError("");
|
|
228
|
+
answered(await call("/ui/app-icon", { method: "DELETE" }), tn("appIconResetDone"));
|
|
229
|
+
};
|
|
230
|
+
const v = encodeURIComponent(state.version);
|
|
231
|
+
return html`<section class="card s-appearance s-app-icon">${title}
|
|
232
|
+
<p class="sub">${tn("appIconLead")}</p>
|
|
233
|
+
<div class="s-app-icon-row">
|
|
234
|
+
<figure class="s-app-icon-now"><img class="s-app-icon-big" src=${`/apple-touch-icon.png?v=${v}`} width="96" height="96" alt=${tn("appIconCurrent")} />
|
|
235
|
+
<img class="s-app-icon-small" src=${`/icon-192.png?v=${v}`} width="32" height="32" alt="" />
|
|
236
|
+
<figcaption>${state.custom ? tn("appIconCustom") : tn("appIconDefault")}</figcaption></figure>
|
|
237
|
+
${picked ? html`<figure class="s-app-icon-new"><img class="s-app-icon-big" src=${picked.url} width="96" height="96" alt=${tn("appIconPreview")} />
|
|
238
|
+
<figcaption>${tn("appIconPreview")}</figcaption></figure>` : null}
|
|
239
|
+
</div>
|
|
240
|
+
${picked && picked.low ? html`<p class="sub s-app-icon-low">${tn("appIconLow")}</p>` : null}
|
|
241
|
+
${error ? html`<p class="err" role="alert">${error}</p>` : null}
|
|
242
|
+
${canWrite ? html`<div class="s-css-actions">
|
|
243
|
+
<input ref=${file} type="file" class="sr-only" id="s-app-icon-file" accept="image/png,image/jpeg,image/webp" onChange=${choose} />
|
|
244
|
+
${picked ? html`
|
|
245
|
+
<button type="button" class="btn btn-primary" disabled=${busy} onClick=${use}><${Icon} name="check" size=${16} />${tn("appIconUse")}</button>
|
|
246
|
+
<button type="button" class="btn" disabled=${busy} onClick=${() => setPicked(null)}>${tn("appIconCancel")}</button>`
|
|
247
|
+
: html`<button type="button" class="btn" disabled=${busy} onClick=${() => file.current && file.current.click()}>${tn("appIconChoose")}</button>`}
|
|
248
|
+
${state.custom && !picked ? html`<button type="button" class="btn" disabled=${busy} onClick=${reset}>${tn("appIconReset")}</button>` : null}
|
|
249
|
+
</div>` : html`<p class="sub">${tn("appIconReadOnly")}</p>`}
|
|
250
|
+
<p class="sub">${tn("appIconWho")}</p>
|
|
251
|
+
<p class="sub">${tn("appIconIos")}</p>
|
|
252
|
+
</section>`;
|
|
253
|
+
}
|
|
254
|
+
|
|
130
255
|
const LIMIT_FIELDS = [["portrait", "max_width"], ["portrait", "max_height"], ["portrait", "max_kib"], ["banner", "max_width"], ["banner", "max_height"], ["banner", "max_kib"],
|
|
131
256
|
["instance_quota", "files"], ["instance_quota", "mib"]];
|
|
132
257
|
/**
|
|
@@ -13,6 +13,7 @@ export const LEARN_ANCHORS = {
|
|
|
13
13
|
colors: ["identity-colours", "識別色"], css: ["custom-css-settings--web-appearance", "自訂-css設定--網頁外觀"], themes: ["agents-themes", "agent-的主題"],
|
|
14
14
|
cards: ["identity-cards", "身分卡"], limits: ["card-upload-limits", "身分卡上傳限制"], widgets: ["widgets", "小工具"], mods: ["mods", "mod"],
|
|
15
15
|
effects: ["effects", "效果"], restore: ["restore-defaults", "還原預設值"],
|
|
16
|
+
"app-icon": ["app-icon", "app-圖示"],
|
|
16
17
|
};
|
|
17
18
|
export function learnHref(topic, language = lang()) {
|
|
18
19
|
const zh = language === "zh-TW";
|