openvibers/openvibe.shared machines.js
machines.js
| 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 | ; |
| 5 | |
| 6 | function safe(value) { return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 |