mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(ui): render Provider Settings from a store
initKeySetup keeps the setup requests, the ChatGPT OAuth flow, the pasted field values and the dialog's keyboard, and publishes the POWER UP chip, the dialog, its key rows and the status line to the gev-provider-settings store. ProviderSettingsView renders them and sends clicks and typing to the connected owner; in Cyber the chip is teleported into the top toolbar. A successful save renders the rows afresh so no field keeps a key, and the status line announces a repeated message again. The voice action opens and closes the dialog through the same controls.
This commit is contained in:
1 parent
6cb01ff975
commit
fe963a1219
9 files changed
+840
-517
No files matched your search
@@ -0,0 +1,421 @@
|
||||
// src/ui/providerSettings.test.mjs — Provider Settings (the dev-only key
|
||||
// setup) renders what src/keySetup.js presents, and its controls reach the
|
||||
// owner, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue';
|
||||
import { initKeySetup } from '../keySetup.js';
|
||||
import { CLOUD_VOICE_AUTH_STORAGE_KEY } from '../voice/cloudVoiceAuth.js';
|
||||
import { useProviderSettingsStore } from './stores/providerSettings.js';
|
||||
import ProviderSettingsView from './views/ProviderSettingsView.vue';
|
||||
|
||||
const STATUS = Object.freeze({
|
||||
total: 3,
|
||||
setCount: 1,
|
||||
store: 'dotenv',
|
||||
keys: [
|
||||
{
|
||||
id: 'google-maps',
|
||||
title: 'Google Maps',
|
||||
tier: 'metered',
|
||||
set: false,
|
||||
managed: null,
|
||||
clientExposed: true,
|
||||
getUrl: 'https://example.test/google',
|
||||
unlocks: 'Photorealistic 3D tiles',
|
||||
envVars: ['GOOGLE_MAPS_API_KEY'],
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
title: 'OpenAI',
|
||||
tier: 'paid',
|
||||
set: true,
|
||||
managed: 'file',
|
||||
getUrl: 'https://example.test/openai',
|
||||
unlocks: 'Cloud voice',
|
||||
envVars: ['OPENAI_API_KEY'],
|
||||
},
|
||||
{
|
||||
id: 'firms',
|
||||
title: 'NASA FIRMS',
|
||||
tier: 'free',
|
||||
set: true,
|
||||
managed: 'external',
|
||||
getUrl: 'https://example.test/firms',
|
||||
unlocks: 'Fire detections',
|
||||
envVars: ['FIRMS_MAP_KEY'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const frame = () =>
|
||||
new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
|
||||
async function fixture(t, { status = STATUS, respond = null } = {}) {
|
||||
document.body.replaceChildren();
|
||||
delete document.documentElement.dataset.uiTheme;
|
||||
const toolbar = document.body.appendChild(document.createElement('div'));
|
||||
toolbar.id = 'top-center-actions';
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(
|
||||
defineComponent(() => () => h(ProviderSettingsView)),
|
||||
).use(pinia);
|
||||
app.mount(host);
|
||||
const store = useProviderSettingsStore(pinia);
|
||||
const requests = [];
|
||||
const fetchImpl = async (url, init = {}) => {
|
||||
requests.push({ url, body: init.body ? JSON.parse(init.body) : null });
|
||||
if (url === '/api/setup/status') {
|
||||
if (!status) return { ok: false, status: 404, json: async () => ({}) };
|
||||
return { ok: true, json: async () => status };
|
||||
}
|
||||
return respond(url, init);
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const setup = await initKeySetup({
|
||||
documentRef: document,
|
||||
fetchImpl,
|
||||
signal: controller.signal,
|
||||
store,
|
||||
});
|
||||
await nextTick();
|
||||
t.after(() => {
|
||||
controller.abort();
|
||||
app.unmount();
|
||||
});
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const text = (selector) => $(selector)?.textContent.trim();
|
||||
return { $, text, store, setup, requests, toolbar };
|
||||
}
|
||||
|
||||
test('an unavailable setup endpoint removes the chip and the dialog', async (t) => {
|
||||
const { $ } = await fixture(t, { status: null });
|
||||
assert.equal($('#key-setup-chip'), null);
|
||||
assert.equal($('#key-setup'), null);
|
||||
assert.ok($('#corner-actions'));
|
||||
});
|
||||
|
||||
test('the chip counts what is missing and the rows render from the status', async (t) => {
|
||||
const { $, text } = await fixture(t);
|
||||
const chip = $('#key-setup-chip');
|
||||
assert.equal(chip.hidden, false);
|
||||
assert.equal(
|
||||
text('[data-key-setup-chip-label]'),
|
||||
'POWER UP · 2 KEYS WAITING',
|
||||
);
|
||||
assert.equal(chip.title, 'Project keys: POWER UP · 2 KEYS WAITING');
|
||||
assert.equal(chip.getAttribute('aria-label'), chip.title);
|
||||
assert.equal($('#key-setup').dataset.ready, 'true');
|
||||
assert.equal($('#key-setup').hidden, true);
|
||||
|
||||
const google = $('[data-key-id="google-maps"]');
|
||||
assert.equal(google.dataset.set, 'false');
|
||||
assert.equal(google.hasAttribute('data-managed'), false);
|
||||
assert.equal(google.querySelector('strong').textContent, 'Google Maps');
|
||||
assert.equal(google.querySelector('.key-setup-tier').textContent, '🔴');
|
||||
assert.equal(
|
||||
google.querySelector('.key-setup-tier').title,
|
||||
'Metered — a billing-enabled account',
|
||||
);
|
||||
assert.equal(
|
||||
google.querySelector('.key-setup-exposed').textContent,
|
||||
'browser-side',
|
||||
);
|
||||
assert.equal(google.querySelector('.key-setup-get').textContent, 'GET KEY ↗');
|
||||
assert.equal(
|
||||
google.querySelector('.key-setup-get').getAttribute('rel'),
|
||||
'noopener noreferrer',
|
||||
);
|
||||
const field = google.querySelector(
|
||||
'input[data-env-var="GOOGLE_MAPS_API_KEY"]',
|
||||
);
|
||||
assert.equal(field.type, 'password');
|
||||
assert.equal(field.placeholder, 'paste GOOGLE_MAPS_API_KEY');
|
||||
assert.equal(google.querySelector('.key-setup-remove'), null);
|
||||
assert.equal(google.querySelector('.key-setup-cloud-auth'), null);
|
||||
|
||||
const openai = $('[data-key-id="openai"]');
|
||||
assert.equal(openai.dataset.managed, 'file');
|
||||
assert.equal(openai.querySelector('.key-setup-get').textContent, 'MANAGE ↗');
|
||||
assert.equal(
|
||||
openai.querySelector('input').placeholder,
|
||||
'OPENAI_API_KEY saved — paste to replace',
|
||||
);
|
||||
const remove = openai.querySelector('.key-setup-remove');
|
||||
assert.equal(remove.textContent, 'REMOVE');
|
||||
assert.equal(remove.title, "Remove OpenAI from this app's saved keys");
|
||||
assert.equal(remove.dataset.keySetupRemove, '["OPENAI_API_KEY"]');
|
||||
assert.equal(
|
||||
text('[data-key-id="openai"] .key-setup-cloud-auth-state'),
|
||||
'VOICE AUTH · API KEY',
|
||||
);
|
||||
|
||||
const firms = $('[data-key-id="firms"]');
|
||||
assert.equal(
|
||||
firms.querySelector('.key-setup-external').textContent,
|
||||
'configured externally',
|
||||
);
|
||||
assert.equal(firms.querySelector('.key-setup-fields'), null);
|
||||
});
|
||||
|
||||
test('the chip opens the dialog onto its first field; closing fades it and restores the status line', async (t) => {
|
||||
const { $, text } = await fixture(t);
|
||||
const chip = $('#key-setup-chip');
|
||||
chip.focus();
|
||||
chip.click();
|
||||
await nextTick();
|
||||
const dialog = $('#key-setup');
|
||||
assert.equal(dialog.hidden, false);
|
||||
assert.equal(dialog.classList.contains('visible'), false);
|
||||
await frame();
|
||||
await wait();
|
||||
assert.equal(dialog.classList.contains('visible'), true);
|
||||
assert.equal(document.activeElement?.dataset.envVar, 'GOOGLE_MAPS_API_KEY');
|
||||
|
||||
$('[data-key-setup-apply]').click();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
text('[data-key-setup-status]'),
|
||||
'Paste at least one key first.',
|
||||
);
|
||||
|
||||
$('[data-key-setup-close]').click();
|
||||
await nextTick();
|
||||
assert.equal(dialog.classList.contains('visible'), false);
|
||||
assert.equal(dialog.hidden, false);
|
||||
assert.equal(
|
||||
text('[data-key-setup-status]'),
|
||||
'The Google Maps key buys the photorealistic planet — everything else stacks on top.',
|
||||
);
|
||||
dialog.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal(dialog.hidden, true);
|
||||
assert.equal(document.activeElement === chip, true);
|
||||
});
|
||||
|
||||
test('saving posts the pasted keys, clears the fields and shows the result', async (t) => {
|
||||
let finish;
|
||||
const { $, text, requests } = await fixture(t, {
|
||||
respond: () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
});
|
||||
const field = $('input[data-env-var="GOOGLE_MAPS_API_KEY"]');
|
||||
field.value = ' AIza-test ';
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const apply = $('[data-key-setup-apply]');
|
||||
assert.equal(apply.hasAttribute('aria-disabled'), false);
|
||||
apply.click();
|
||||
await nextTick();
|
||||
assert.equal(apply.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(text('[data-key-setup-status]'), 'Saving…');
|
||||
assert.deepEqual(requests.at(-1), {
|
||||
url: '/api/setup/keys',
|
||||
body: { GOOGLE_MAPS_API_KEY: 'AIza-test' },
|
||||
});
|
||||
finish({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
saved: ['GOOGLE_MAPS_API_KEY'],
|
||||
status: {
|
||||
...STATUS,
|
||||
setCount: 3,
|
||||
keys: STATUS.keys.map((key) => ({ ...key, set: true })),
|
||||
},
|
||||
}),
|
||||
});
|
||||
await wait();
|
||||
await nextTick();
|
||||
assert.equal(apply.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(
|
||||
text('[data-key-setup-status]'),
|
||||
'Saved to your local .env. Restarting — this page reloads itself.',
|
||||
);
|
||||
assert.equal($('input[data-env-var="GOOGLE_MAPS_API_KEY"]').value, '');
|
||||
assert.equal(
|
||||
$('input[data-env-var="GOOGLE_MAPS_API_KEY"]').placeholder,
|
||||
'GOOGLE_MAPS_API_KEY saved — paste to replace',
|
||||
);
|
||||
// Fully powered retires the chip.
|
||||
assert.equal($('#key-setup-chip').hidden, true);
|
||||
});
|
||||
|
||||
test('removing a saved key asks first and posts nulls', async (t) => {
|
||||
const { $, requests } = await fixture(t, {
|
||||
respond: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, saved: [], status: STATUS }),
|
||||
}),
|
||||
});
|
||||
const original = globalThis.confirm;
|
||||
const asked = [];
|
||||
let answer = false;
|
||||
globalThis.confirm = (message) => {
|
||||
asked.push(message);
|
||||
return answer;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.confirm = original;
|
||||
});
|
||||
$('.key-setup-remove').click();
|
||||
assert.deepEqual(asked, ['Remove this key from your saved configuration?']);
|
||||
assert.equal(requests.length, 1);
|
||||
answer = true;
|
||||
$('.key-setup-remove').click();
|
||||
await wait();
|
||||
assert.deepEqual(requests.at(-1), {
|
||||
url: '/api/setup/keys',
|
||||
body: { OPENAI_API_KEY: null },
|
||||
});
|
||||
});
|
||||
|
||||
test('the cloud voice toggle switches back to the API key and says so', async (t) => {
|
||||
localStorage.setItem(CLOUD_VOICE_AUTH_STORAGE_KEY, 'oauth');
|
||||
t.after(() => localStorage.removeItem(CLOUD_VOICE_AUTH_STORAGE_KEY));
|
||||
const { $, text } = await fixture(t);
|
||||
const toggle = $('[data-cloud-voice-auth-toggle]');
|
||||
assert.equal(
|
||||
text('.key-setup-cloud-auth-state'),
|
||||
'VOICE AUTH · CHATGPT OAUTH',
|
||||
);
|
||||
assert.equal(toggle.textContent, 'USE API KEY');
|
||||
toggle.click();
|
||||
await nextTick();
|
||||
assert.equal(localStorage.getItem(CLOUD_VOICE_AUTH_STORAGE_KEY), 'api-key');
|
||||
assert.equal(text('.key-setup-cloud-auth-state'), 'VOICE AUTH · API KEY');
|
||||
assert.equal(toggle.textContent, 'USE CHATGPT OAUTH');
|
||||
assert.equal(toggle.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(
|
||||
text('[data-key-setup-status]'),
|
||||
'Cloud voice will use OPENAI_API_KEY on the next session.',
|
||||
);
|
||||
});
|
||||
|
||||
test('while the OAuth check runs, the toggle is busy and repeat presses are ignored', async (t) => {
|
||||
let finish;
|
||||
const calls = [];
|
||||
const { $, text } = await fixture(t, {
|
||||
respond: (url) => {
|
||||
calls.push(url);
|
||||
return new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const toggle = $('[data-cloud-voice-auth-toggle]');
|
||||
toggle.click();
|
||||
await nextTick();
|
||||
assert.equal(toggle.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(
|
||||
text('[data-key-setup-status]'),
|
||||
'Checking local ChatGPT OAuth sign-in…',
|
||||
);
|
||||
toggle.click();
|
||||
assert.deepEqual(calls, ['/api/realtime/oauth-status']);
|
||||
finish({ ok: true, json: async () => ({ available: true }) });
|
||||
await wait();
|
||||
await nextTick();
|
||||
assert.equal(toggle.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(
|
||||
text('.key-setup-cloud-auth-state'),
|
||||
'VOICE AUTH · CHATGPT OAUTH',
|
||||
);
|
||||
localStorage.removeItem(CLOUD_VOICE_AUTH_STORAGE_KEY);
|
||||
});
|
||||
|
||||
test('Cyber moves the chip into the top toolbar and other themes return it to the corner', async (t) => {
|
||||
const { $, toolbar } = await fixture(t);
|
||||
const corner = $('#corner-actions');
|
||||
assert.equal(corner.firstElementChild?.id, 'key-setup-chip');
|
||||
document.documentElement.dataset.uiTheme = 'cyber';
|
||||
await wait();
|
||||
await nextTick();
|
||||
assert.equal(toolbar.lastElementChild?.id, 'key-setup-chip');
|
||||
assert.equal(corner.querySelector('#key-setup-chip'), null);
|
||||
document.documentElement.dataset.uiTheme = 'tactical';
|
||||
await wait();
|
||||
await nextTick();
|
||||
assert.equal(corner.firstElementChild?.id, 'key-setup-chip');
|
||||
assert.equal(toolbar.querySelector('#key-setup-chip'), null);
|
||||
delete document.documentElement.dataset.uiTheme;
|
||||
});
|
||||
|
||||
test('a field focused while a save completes does not keep the saved key', async (t) => {
|
||||
let finish;
|
||||
const { $ } = await fixture(t, {
|
||||
respond: () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
});
|
||||
const field = $('input[data-env-var="GOOGLE_MAPS_API_KEY"]');
|
||||
field.value = 'AIza-test';
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
$('[data-key-setup-apply]').click();
|
||||
await nextTick();
|
||||
field.focus();
|
||||
finish({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, saved: [], status: STATUS }),
|
||||
});
|
||||
await wait();
|
||||
await nextTick();
|
||||
const fresh = $('input[data-env-var="GOOGLE_MAPS_API_KEY"]');
|
||||
assert.equal(fresh.value, '');
|
||||
assert.equal(fresh.isConnected, true);
|
||||
});
|
||||
|
||||
test('a repeated status message is written again for the live region', async (t) => {
|
||||
const { $ } = await fixture(t);
|
||||
const line = $('[data-key-setup-status]');
|
||||
const writes = [];
|
||||
const observer = new MutationObserver((records) =>
|
||||
writes.push(records.length),
|
||||
);
|
||||
observer.observe(line, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
});
|
||||
t.after(() => observer.disconnect());
|
||||
$('[data-key-setup-apply]').click();
|
||||
await nextTick();
|
||||
await wait();
|
||||
const first = writes.length;
|
||||
assert.ok(first > 0);
|
||||
$('[data-key-setup-apply]').click();
|
||||
await nextTick();
|
||||
await wait();
|
||||
assert.ok(writes.length > first);
|
||||
assert.equal(line.textContent, 'Paste at least one key first.');
|
||||
});
|
||||
|
||||
test('the chip does nothing until the setup endpoint has answered', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(ProviderSettingsView).use(pinia);
|
||||
app.mount(host);
|
||||
const store = useProviderSettingsStore(pinia);
|
||||
const controller = new AbortController();
|
||||
t.after(() => {
|
||||
controller.abort();
|
||||
app.unmount();
|
||||
});
|
||||
void initKeySetup({
|
||||
documentRef: document,
|
||||
fetchImpl: () => new Promise(() => {}),
|
||||
signal: controller.signal,
|
||||
store,
|
||||
});
|
||||
await nextTick();
|
||||
store.controls.open();
|
||||
await nextTick();
|
||||
assert.equal(document.getElementById('key-setup').hidden, true);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* Provider Settings (the dev-only key setup) as its view renders it. The
|
||||
* setup owner (src/keySetup.js) presents the POWER UP chip, the dialog, its
|
||||
* key rows, the field values and the status line, and publishes them here;
|
||||
* the view sends clicks and typing to the connected owner.
|
||||
*/
|
||||
export const useProviderSettingsStore = defineStore(
|
||||
'gev-provider-settings',
|
||||
() => {
|
||||
/** The presented setup; null until the owner first publishes. */
|
||||
const view = shallowRef(null);
|
||||
|
||||
/** @param {?object} next A fresh presentation; every publish re-renders. */
|
||||
function publish(next) {
|
||||
view.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
/** The setup owner the controls call, or null while none is wired. */
|
||||
const controls = shallowRef(null);
|
||||
|
||||
/** @param {?object} next The owner, or null to disconnect. */
|
||||
function connect(next) {
|
||||
controls.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
return { view, publish, controls, connect };
|
||||
},
|
||||
);
|
||||
@@ -1,40 +1,112 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import { vFieldValue } from '../rail/fieldValue.js';
|
||||
import { useLocaleStore } from '../stores/locale.js';
|
||||
import { useProviderSettingsStore } from '../stores/providerSettings.js';
|
||||
import LocalePicker from './panels/LocalePicker.vue';
|
||||
|
||||
const { picker } = storeToRefs(useLocaleStore());
|
||||
// The key setup owner (src/keySetup.js) presents the chip, the dialog and its
|
||||
// rows; until it does, the chip stays hidden and the dialog closed. When the
|
||||
// setup endpoint is not there, the owner reports it unavailable and both go.
|
||||
const { view, controls } = storeToRefs(useProviderSettingsStore());
|
||||
const available = computed(() => view.value?.available !== false);
|
||||
const chip = computed(() => view.value?.chip ?? null);
|
||||
const dialog = computed(() => view.value?.dialog ?? { hidden: true, visible: false });
|
||||
|
||||
// Cyber puts the chip in the top toolbar; every other theme keeps it first in
|
||||
// the corner row, beside the language picker.
|
||||
const cyber = shallowRef(false);
|
||||
let themeObserver = null;
|
||||
onMounted(() => {
|
||||
const theme = document.documentElement;
|
||||
const sync = () => {
|
||||
cyber.value =
|
||||
theme.dataset.uiTheme === 'cyber' &&
|
||||
Boolean(document.getElementById('top-center-actions'));
|
||||
};
|
||||
themeObserver = new MutationObserver(sync);
|
||||
themeObserver.observe(theme, { attributes: true, attributeFilter: ['data-ui-theme'] });
|
||||
sync();
|
||||
});
|
||||
onBeforeUnmount(() => themeObserver?.disconnect());
|
||||
|
||||
// The status line is a live region: write each message, even a repeated one,
|
||||
// so a screen reader announces it again.
|
||||
const vStatusText = {
|
||||
mounted(el, { value }) {
|
||||
el.textContent = value.text;
|
||||
},
|
||||
updated(el, { value, oldValue }) {
|
||||
if (value.seq !== oldValue?.seq || value.text !== oldValue?.text)
|
||||
el.textContent = value.text;
|
||||
},
|
||||
};
|
||||
const statusText = computed(() => ({
|
||||
text:
|
||||
view.value?.status ??
|
||||
t('The Google Maps key buys the photorealistic planet — everything else stacks on top.'),
|
||||
seq: view.value?.statusSeq ?? 0,
|
||||
}));
|
||||
const removeTitle = (title) =>
|
||||
t("Remove {title} from this app's saved keys", { title });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes
|
||||
both the chip and this dialog outright when /api/setup/status is not
|
||||
there to answer (prod builds, LAN visitors). Rows are rendered from
|
||||
that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
|
||||
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js reports
|
||||
it unavailable, removing both the chip and this dialog, when
|
||||
/api/setup/status is not there to answer (prod builds, LAN visitors).
|
||||
Rows are rendered from that payload, so the key registry lives in
|
||||
src/keySetupCore.mjs alone. -->
|
||||
<!-- Bottom-right HUD row: the POWER UP chip (while keys are missing) and
|
||||
the language picker, once the shell publishes it. -->
|
||||
<div id="corner-actions">
|
||||
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
|
||||
<span data-key-setup-chip-label data-i18n>POWER UP</span>
|
||||
</button>
|
||||
<Teleport v-if="available" :to="cyber ? '#top-center-actions' : 'body'" :disabled="!cyber">
|
||||
<button id="key-setup-chip" type="button" :hidden="!chip || chip.hidden" aria-haspopup="dialog" aria-controls="key-setup" :title="chip?.title" :aria-label="chip?.title" @click="controls?.open()">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
|
||||
<span data-key-setup-chip-label>{{ chip ? chip.label : t('POWER UP') }}</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<LocalePicker v-if="picker" :key="picker.current" :current="picker.current" :on-select="picker.onSelect" />
|
||||
</div>
|
||||
<aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden>
|
||||
<aside v-if="available" id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" :hidden="dialog.hidden" v-class-toggle="{ visible: dialog.visible }" :data-ready="view?.ready ? 'true' : undefined" @transitionend="controls?.transitionEnd()">
|
||||
<div class="key-setup-scanline" aria-hidden="true"></div>
|
||||
<header class="key-setup-header">
|
||||
<span class="key-setup-kicker" data-i18n>GROUND STATION · PROVIDER SETTINGS</span>
|
||||
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup" data-i18n-aria-label>
|
||||
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup" data-i18n-aria-label @click="controls?.close()">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</header>
|
||||
<h2 id="key-setup-title" data-i18n>Power up the globe</h2>
|
||||
<p id="key-setup-description" data-i18n>The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.</p>
|
||||
<div class="key-setup-rows ui-scroll" data-key-setup-rows></div>
|
||||
<div class="key-setup-rows ui-scroll" data-key-setup-rows>
|
||||
<section v-for="key in view?.rows ?? []" :key="`${key.id}:${view.generation}`" class="key-setup-row" :data-key-id="key.id" :data-set="String(key.set)" :data-managed="key.managed || undefined">
|
||||
<div class="key-setup-row-head">
|
||||
<span class="key-setup-led" aria-hidden="true"></span>
|
||||
<strong>{{ key.title }}</strong>
|
||||
<span class="key-setup-tier" :title="key.tierTitle">{{ key.tierDot }}</span>
|
||||
<span v-if="key.clientExposed" class="key-setup-exposed" :title="t('This key runs in the browser by design — restrict it at the provider (see SECURITY.md)')">{{ t('browser-side') }}</span>
|
||||
<span v-if="key.external" class="key-setup-external" :title="t('Supplied by your environment, Keychain, or launcher — change it where it was set')">{{ t('configured externally') }}</span>
|
||||
<a class="key-setup-get" :href="key.getUrl" target="_blank" rel="noopener noreferrer">{{ key.set ? t('MANAGE ↗') : t('GET KEY ↗') }}</a>
|
||||
</div>
|
||||
<p class="key-setup-unlocks">{{ key.unlocks }}</p>
|
||||
<div v-if="!key.external" class="key-setup-fields">
|
||||
<input v-for="envVar in key.envVars" :key="envVar" type="password" autocomplete="off" spellcheck="false" :data-env-var="envVar" :aria-label="envVar" :placeholder="key.set ? t('{envVar} saved — paste to replace', { envVar }) : t('paste {envVar}', { envVar })" v-field-value="view.fields[envVar] ?? ''" @input="controls?.setField(envVar, $event.target.value)" />
|
||||
<button v-if="key.managed === 'file'" type="button" class="key-setup-remove" :data-key-setup-remove="JSON.stringify(key.envVars)" :title="removeTitle(key.title)" @click="controls?.remove(key.envVars)">{{ t('REMOVE') }}</button>
|
||||
</div>
|
||||
<div v-if="key.id === 'openai'" class="key-setup-cloud-auth">
|
||||
<span class="key-setup-cloud-auth-state">{{ view.auth.mode === 'oauth' ? t('VOICE AUTH · CHATGPT OAUTH') : t('VOICE AUTH · API KEY') }}</span>
|
||||
<button type="button" class="key-setup-cloud-auth-toggle" data-cloud-voice-auth-toggle="true" :title="view.auth.mode === 'oauth' ? t('Use OPENAI_API_KEY for the next cloud voice session') : t('Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session')" :aria-disabled="String(view.auth.busy)" @click="controls?.toggleAuth()">{{ view.auth.mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="key-setup-footer">
|
||||
<button type="button" class="key-setup-apply" data-key-setup-apply data-i18n>SAVE KEYS</button>
|
||||
<button type="button" class="key-setup-apply" data-key-setup-apply :aria-disabled="view?.saving == null ? undefined : String(view.saving)" data-i18n @click="controls?.apply()">SAVE KEYS</button>
|
||||
<span class="key-setup-hint" data-i18n>ESC to close</span>
|
||||
</div>
|
||||
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite" data-i18n>The Google Maps key buys the photorealistic planet — everything else stacks on top.</p>
|
||||
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite" v-status-text="statusText"></p>
|
||||
</aside>
|
||||
</template>
|
||||
Reference in new issue
Block a user