feat(ext): org context switcher (popup header + vault sidebar) + offline read-only banner

This commit is contained in:
adlee-was-taken
2026-06-27 11:31:20 -04:00
parent aceeb30af9
commit 37fb76ab97
5 changed files with 67 additions and 11 deletions

View File

@@ -5,21 +5,68 @@
// (and its offline flag) into shared state, and reloads the list. An offline
// org renders a read-only banner; Dev-C's write UI reads `orgOffline` to
// disable writes.
//
// SCAFFOLD STATE (HOLD until Dev-A merges): renders the select shell only. The
// `org_list_configs` fetch, the `org_switch` dispatch + list reload, and the
// offline banner are held until Dev-A's org messages land in the Request union.
import type { OrgConfigSummary } from '../../shared/types';
import { sendMessage, setState, navigate, getState } from '../../shared/state';
// Module-level teardown (one instance per browser context; popup and vault
// each have their own module scope).
let _cleanupListener: (() => void) | null = null;
export async function renderOrgSwitcher(host: HTMLElement): Promise<void> {
// HOLD(dev-a): fetch org_list_configs -> append an <option> per org; on change
// send org_switch, setState({ orgContext, orgOffline }), navigate('list', {}),
// and render the "org offline — writes disabled" banner when data.offline.
// Tear down any previous instance before re-mounting.
teardown();
// 1. Fetch org configs to populate the switcher options.
const resp = await sendMessage({ type: 'org_list_configs' });
const orgConfigs: OrgConfigSummary[] = resp.ok
? (resp.data as OrgConfigSummary[])
: [];
// 2. Current context (default: 'personal').
const currentContext = getState().orgContext ?? 'personal';
// 3. Build and inject the <select>.
let optionsHtml =
`<option value="personal"${currentContext === 'personal' ? ' selected' : ''}>Personal</option>`;
for (const org of orgConfigs) {
const sel = currentContext === org.orgId ? ' selected' : '';
optionsHtml += `<option value="${org.orgId}"${sel}>${org.displayName}</option>`;
}
host.innerHTML =
'<select class="org-switcher" aria-label="Vault context">' +
'<option value="personal">Personal</option>' +
'</select>';
`<select class="org-switcher" aria-label="Vault context">${optionsHtml}</select>`;
// 4. Wire the change event.
const select = host.querySelector('select') as HTMLSelectElement;
const handler = async (_e: Event) => {
const context = select.value;
const switchResp = await sendMessage({ type: 'org_switch', context });
if (!switchResp.ok) return;
const data = switchResp.data as { context: string; offline: boolean };
setState({ orgContext: data.context, orgOffline: data.offline });
// Offline banner — toggle inside host alongside the select.
const existing = host.querySelector('.org-offline-banner');
if (data.offline) {
if (!existing) {
const banner = document.createElement('div');
banner.className = 'org-offline-banner';
banner.textContent = 'org offline — writes disabled';
host.appendChild(banner);
}
} else {
existing?.remove();
}
navigate('list', {});
};
select.addEventListener('change', handler);
_cleanupListener = () => select.removeEventListener('change', handler);
}
export function teardown(): void {
// HOLD(dev-a): detach the change listener once renderOrgSwitcher wires it.
_cleanupListener?.();
_cleanupListener = null;
}

View File

@@ -7,6 +7,7 @@
<title>Relicario</title>
</head>
<body class="surface-backdrop">
<div id="popup-header"></div>
<div id="app"></div>
<script src="popup.js"></script>
</body>

View File

@@ -14,6 +14,7 @@ import { renderItemDetail } from './components/item-detail';
import { renderItemForm } from './components/item-form';
import { renderSettings, teardownSettings } from './components/settings';
import { renderVaultSettings } from './components/settings-vault';
import { renderOrgSwitcher } from './components/org-switcher';
import { renderTrash } from './components/trash';
import { renderDevices } from './components/devices';
import { renderFieldHistory } from './components/field-history';
@@ -245,6 +246,10 @@ async function init(): Promise<void> {
currentState.generatorDefaults = vs.generator_defaults;
}
// Mount the org context switcher in the persistent header slot (lives
// outside #app so it survives view swaps).
void renderOrgSwitcher(document.getElementById('popup-header')!);
// Check URL params for deep linking (when opened in tab)
const urlParams = parseUrlParams();
if (urlParams) {

View File

@@ -11,6 +11,7 @@ import {
type VaultController, escapeHtml, typeIcon,
} from './vault-context';
import { renderSidebarShell } from './vault-sidebar';
import { renderOrgSwitcher } from '../popup/components/org-switcher';
// ---------------------------------------------------------------------------
// Type picker (right side panel)
@@ -118,6 +119,7 @@ export function renderShell(ctx: VaultController, app: HTMLElement): void {
`;
ctx.wireSidebar();
wireTypePanel(ctx);
void renderOrgSwitcher(document.getElementById('org-switcher-slot')!);
}
applyShellViewClass(ctx);

View File

@@ -27,6 +27,7 @@ export function renderSidebarShell(): string {
<div class="vault-sidebar__header">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<span class="brand">Relicario</span>
<div id="org-switcher-slot"></div>
</div>
<div class="vault-sidebar__search">
<input type="text" id="vault-search" placeholder="/ search…" />