openvibers/openvibe.shared machines.js

machines.js

main 13.8 KB · 157 lines · javascript

1/* Shared picker for a person's OpenVibe Link machines. Pages provide a signed-in
2 * Network token through OpenVibeNavbar and may declare data-needs for filtering. */
3(function (root) {
4 'use strict';
5
6 function safe(value) { return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
7 function canRun(machine) {
8 const roles = Array.isArray(machine.roles) ? machine.roles : [];
9 return roles.map((role) => ({ 'codes.worker': 'coding jobs', 'desks.host': 'browser desks', 'ai.host': 'your AI models', 'bot.robot': 'robot controls' }[role] || role)).join(', ') || 'Link connection';
10 }
11 function needsMachine(machine, needs) {
12 if (!needs) return true;
13 const roles = Array.isArray(machine.roles) ? machine.roles : [];
14 const map = { desktop: ['desks.host'], browser: ['desks.host'], models: ['ai.host'], worker: ['codes.worker'] };
15 return (map[needs] || [needs]).some((role) => roles.includes(role));
16 }
17 function sessionToken() {
18 try { const nav = root.OpenVibeNavbar; return nav && nav.session && nav.session.token || null; } catch (_) { return null; }
19 }
20 const svg = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
21 const G = {
22 pc: svg('<rect x="3" y="4.5" width="18" height="12" rx="2.5"/><path d="M8.5 20h7M12 16.5V20"/>'),
23 phone: svg('<rect x="7" y="2.5" width="10" height="19" rx="3"/><path d="M10.5 18h3"/>'),
24 auto: svg('<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9L12 17.5l-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>'),
25 plus: svg('<path d="M12 5.5v13M5.5 12h13"/>'),
26 lock: svg('<rect x="5" y="10.5" width="14" height="9.5" rx="2.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
27 off: svg('<path d="M4 4l16 16M8.6 8.7A6.5 6.5 0 0 0 5.5 12M18.5 12a6.5 6.5 0 0 0-5-6.3M2.5 9a12 12 0 0 1 3-2.3M21.5 9a12 12 0 0 0-6-3.4M12 18.5v.01"/>'),
28 };
29 // "2026-11-10T08:14:00Z" → "3 h ago": when a computer was last seen, as a person would say it.
30 function ago(iso) {
31 const t = Date.parse(iso); if (!isFinite(t)) return String(iso || '');
32 const m = Math.max(0, Math.round((Date.now() - t) / 60000));
33 if (m < 2) return 'just now'; if (m < 60) return `${m} min ago`;
34 const h = Math.round(m / 60); if (h < 24) return `${h} h ago`;
35 const d = Math.round(h / 24); return d < 30 ? `${d} ${d === 1 ? 'day' : 'days'} ago` : new Date(t).toISOString().slice(0, 10);
36 }
37 const CSS = `
38.ovmc { display: grid; gap: 8px; }
39.ovmc p { margin: 0; }
40.ovmc-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
41.ovmc-row { position: relative; margin: 0; padding: 0; border: 0; background: none; }
42.ovmc-row > label { position: relative; display: flex; align-items: center; gap: 12px; min-height: 60px; box-sizing: border-box; padding: 8px 14px 8px 10px; border-radius: 16px; cursor: pointer; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4.5%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); transition: background-color .15s, box-shadow .2s, transform .12s; }
43.ovmc-row > label:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); }
44.ovmc-row > label:active { transform: scale(.985); }
45.ovmc-row input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
46.ovmc-row:has(input:checked) > label { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
47.ovmc-row:has(input:focus-visible) > label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
48.ovmc-ic { flex: none; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); }
49.ovmc-row.is-off .ovmc-ic { color: var(--text-muted, #93a0b4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
50.ovmc-row .ovk-row-main { display: grid; gap: 2px; min-width: 0; flex: 1; }
51.ovmc-row .ovk-row-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
52.ovmc-row strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
53.ovmc-row .ovk-row-sub { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
54.ovmc-state { flex: none; display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted, #93a0b4); }
55.ovmc-state::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .6; }
56.ovmc-state.is-on { color: var(--success-text, #34d399); }
57.ovmc-state.is-on::before { opacity: 1; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 22%, transparent); animation: ovmc-on 2.4s ease-in-out infinite; }
58@keyframes ovmc-on { 50% { box-shadow: 0 0 0 calc(3px + 3px * var(--ov-move, 1)) color-mix(in srgb, currentColor 6%, transparent); } }
59.ovmc-tick { flex: none; width: 22px; height: 22px; border-radius: 50%; box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--text-primary, #e6edf7) 26%, transparent); transition: background-color .15s, box-shadow .15s; }
60.ovmc-row:has(input:checked) .ovmc-tick { background: var(--accent, #3b82f6); box-shadow: none; position: relative; }
61.ovmc-row:has(input:checked) .ovmc-tick::after { content: ""; position: absolute; left: 8px; top: 4.5px; width: 5px; height: 10px; border-right: 2.4px solid #fff; border-bottom: 2.4px solid #fff; transform: rotate(45deg); animation: ovmc-tick .25s cubic-bezier(.2, 1.6, .4, 1) both; }
62@keyframes ovmc-tick { from { transform: rotate(45deg) scale(0); } }
63.ovmc-note { display: flex; align-items: center; gap: 12px; padding: 8px 12px 8px 10px; border-radius: 16px; font-size: 14px; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 4.5%, transparent); }
64.ovmc-note .ovmc-ic { color: var(--text-muted, #93a0b4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
65.ovmc-wait { display: grid; gap: 6px; }
66.ovmc-wait span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
67.ovmc-wait i { display: block; height: 60px; border-radius: 16px; background: linear-gradient(100deg, color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent) 40%, color-mix(in srgb, var(--text-primary, #e6edf7) 11%, transparent) 50%, color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent) 60%) 0 0 / 220% 100%; animation: ovmc-wait 1.4s ease-in-out infinite; }
68.ovmc-wait i + i { opacity: .6; animation-delay: .15s; }
69@keyframes ovmc-wait { to { background-position: -120% 0; } }
70.ovmc-add a.ovk-btn { display: flex; align-items: center; justify-content: flex-start; gap: 12px; width: 100%; min-height: 60px; box-sizing: border-box; padding: 8px 14px 8px 10px; border-radius: 16px; text-align: left; text-decoration: none; color: inherit; background: none; border: 1.5px dashed color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); transition: background-color .15s, border-color .15s, transform .12s; }
71.ovmc-add a.ovk-btn:hover { background: color-mix(in srgb, var(--accent, #3b82f6) 9%, transparent); border-color: var(--accent, #3b82f6); }
72.ovmc-add a.ovk-btn:active { transform: scale(.985); }
73.ovmc-add b { display: block; font-size: 15px; }
74.ovmc-add small { display: block; font-size: 12.5px; font-weight: 500; color: var(--text-secondary, #a8b3c4); }`;
75 function styles() {
76 if (!root.document || root.document.getElementById('ovmc-style')) return;
77 const st = root.document.createElement('style'); st.id = 'ovmc-style'; st.textContent = CSS; root.document.head.appendChild(st);
78 }
79 function mount(host, options) {
80 styles();
81 options = options || {};
82 if (!host) throw new TypeError('A machine picker host is required');
83 const needs = options.needs || host.getAttribute('data-needs') || '';
84 const inputName = options.name || host.getAttribute('data-name') || 'machine';
85 const autoLabel = options.autoLabel || host.getAttribute('data-auto-label') || '';
86 const principalName = options.principalName || host.getAttribute('data-principal-name') || '';
87 let principalValue = host.getAttribute('data-principal-value') || '';
88 let selected = options.value || host.getAttribute('data-value') || (host.querySelector('input[type="hidden"]') || {}).value || '';
89 let machines = [];
90 let state = 'loading';
91 const addUrl = (options.addUrl || host.getAttribute('data-add-url') || 'https://openvibe.link/add-computer');
92 function paint() {
93 const hidden = host.querySelector('input[type="hidden"]');
94 const value = hidden ? hidden.value : selected;
95 let html = '';
96 const row = (id, checked, glyph, name, meta, sub, cls) => `<li class="ovk-row ovmc-row${cls || ''}"><label class="ovk-row--static"><input type="radio" name="${safe(inputName)}__choice" value="${safe(id)}"${checked ? ' checked' : ''}><span class="ovmc-ic" aria-hidden="true">${glyph}</span><span class="ovk-row-main"><span class="ovk-row-top"><strong>${safe(name)}</strong>${meta}</span>${sub ? `<span class="ovk-row-sub"><span>${safe(sub)}</span></span>` : ''}</span><span class="ovmc-tick" aria-hidden="true"></span></label></li>`;
97 const auto = (checked) => (autoLabel ? row('', checked, G.auto, autoLabel, '', 'OpenVibe picks where it runs') : '');
98 const note = (glyph, text) => `<p class="ovmc-note" role="status"><span class="ovmc-ic" aria-hidden="true">${glyph}</span><span>${text}</span></p>`;
99 if (state === 'loading') html = '<p class="ovmc-wait" role="status"><span>Loading your Link computers…</span><i></i><i></i></p>';
100 else if (state === 'signed-out') html = note(G.lock, 'Sign in to choose a computer.');
101 else if (state === 'unreachable') html = note(G.off, 'Link could not be reached. Your current choice is kept.');
102 else if (!machines.length) html = note(G.pc, needs ? 'None of your computers can run this yet.' : 'You have no computers on Link yet.') + (autoLabel ? `<ul class="ovk-list ovmc-list">${auto(true)}</ul>` : '');
103 else {
104 html = '<ul class="ovk-list ovmc-list">' + auto(!value) + machines.map((m) => {
105 const system = m.system || m.os || m.kind || 'Link computer';
106 const meta = `<span class="ovk-row-meta ovmc-state${m.online ? ' is-on' : ''}">${m.online ? 'Online' : (m.last_seen_at ? `Seen ${safe(ago(m.last_seen_at))}` : 'Offline')}</span>`;
107 return row(m.id, m.id === value, /phone|android|ios/i.test(system) ? G.phone : G.pc, m.name || m.id, meta, `${system} · Can run: ${canRun(m)}`, m.online ? '' : ' is-off');
108 }).join('') + '</ul>';
109 }
110 html += `<p class="ovmc-add"><a class="ovk-btn ovk-btn--quiet" href="${safe(addUrl)}?return_to=${encodeURIComponent(location.href)}"><span class="ovmc-ic" aria-hidden="true">${G.plus}</span><span><b>Add a computer</b><small>Through OpenVibe Link, once, for every site</small></span></a></p>`;
111 host.classList.add('ovmc');
112 host.innerHTML = html;
113 let hiddenInput = host.querySelector('input[type="hidden"]');
114 if (!hiddenInput) { hiddenInput = document.createElement('input'); hiddenInput.type = 'hidden'; host.appendChild(hiddenInput); }
115 hiddenInput.name = inputName; hiddenInput.value = selected;
116 host.value = selected;
117 if (principalName) {
118 let principal = host.querySelector('input[type="hidden"][data-machine-principal]');
119 if (!principal) { principal = document.createElement('input'); principal.type = 'hidden'; principal.setAttribute('data-machine-principal', ''); host.appendChild(principal); }
120 principal.name = principalName;
121 principal.value = (machines.find((m) => m.id === selected) || {}).principal_id || principalValue;
122 }
123 host.querySelectorAll('input[type="radio"]').forEach((radio) => radio.addEventListener('change', () => {
124 if (!radio.checked) return;
125 selected = radio.value; hiddenInput.value = selected;
126 host.value = selected;
127 const chosen = machines.find((m) => m.id === selected);
128 if (principalName) {
129 const principal = host.querySelector('input[type="hidden"][data-machine-principal]');
130 principalValue = chosen && chosen.principal_id || '';
131 if (principal) principal.value = principalValue;
132 }
133 host.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { id: selected, machine: chosen || null } }));
134 }));
135 }
136 async function load() {
137 const token = options.getToken ? await options.getToken() : sessionToken();
138 if (!token) { state = 'signed-out'; paint(); return; }
139 state = 'loading'; paint();
140 try {
141 const api = options.apiBase || host.getAttribute('data-api-base') || 'https://openvibe.network';
142 const response = await (options.fetch || fetch)(`${api.replace(/\/$/, '')}/api/v1/machines`, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' }, credentials: 'omit' });
143 if (!response.ok) throw new Error(`Link returned ${response.status}`);
144 const data = await response.json();
145 machines = (Array.isArray(data.machines) ? data.machines : []).filter((m) => m && typeof m.id === 'string' && needsMachine(m, needs));
146 state = 'ready';
147 } catch (_) { state = 'unreachable'; }
148 paint();
149 }
150 paint(); load();
151 return { reload: load, get value() { return selected; }, setValue(id) { selected = String(id || ''); paint(); } };
152 }
153 root.OpenVibeMachines = { mount, needsMachine, canRun };
154 if (root.document) root.document.querySelectorAll('[data-ov-machines]').forEach((host) => mount(host));
155 if (typeof module === 'object' && module.exports) module.exports = root.OpenVibeMachines;
156})(typeof window !== 'undefined' ? window : globalThis);
157