feat: add vault initialization wizard
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
592
extension/src/setup/setup.ts
Normal file
592
extension/src/setup/setup.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
/// Vault initialization wizard — 4-step flow for creating new idfoto vaults.
|
||||
///
|
||||
/// Step 1: Choose host type (Gitea / GitHub)
|
||||
/// Step 2: Configure connection (URL, repo, token) + test
|
||||
/// Step 3: Create vault (carrier image, passphrase, generate secrets, push files)
|
||||
/// Step 4: Finish (download reference image, push config to extension or copy JSON)
|
||||
|
||||
import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
|
||||
import type { GitHost } from '../service-worker/git-host';
|
||||
import type { VaultConfig } from '../shared/types';
|
||||
|
||||
// --- WASM module (loaded dynamically) ---
|
||||
|
||||
type WasmModule = typeof import('idfoto-wasm');
|
||||
let wasm: WasmModule | null = null;
|
||||
|
||||
async function loadWasm(): Promise<WasmModule> {
|
||||
if (wasm) return wasm;
|
||||
const mod = await import(
|
||||
// @ts-ignore TS2307 — resolved at runtime, not by TS/webpack
|
||||
/* webpackIgnore: true */ '../idfoto_wasm.js'
|
||||
) as WasmModule & { default: (input?: string | URL) => Promise<void> };
|
||||
await mod.default('../idfoto_wasm_bg.wasm');
|
||||
wasm = mod;
|
||||
return mod;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
interface WizardState {
|
||||
step: number;
|
||||
hostType: 'gitea' | 'github';
|
||||
hostUrl: string;
|
||||
repoPath: string;
|
||||
apiToken: string;
|
||||
connectionTested: boolean;
|
||||
carrierImageBytes: Uint8Array | null;
|
||||
passphrase: string;
|
||||
passphraseConfirm: string;
|
||||
referenceImageBytes: Uint8Array | null;
|
||||
creating: boolean;
|
||||
error: string | null;
|
||||
extensionDetected: boolean;
|
||||
configPushed: boolean;
|
||||
}
|
||||
|
||||
const state: WizardState = {
|
||||
step: 1,
|
||||
hostType: 'gitea',
|
||||
hostUrl: '',
|
||||
repoPath: '',
|
||||
apiToken: '',
|
||||
connectionTested: false,
|
||||
carrierImageBytes: null,
|
||||
passphrase: '',
|
||||
passphraseConfirm: '',
|
||||
referenceImageBytes: null,
|
||||
creating: false,
|
||||
error: null,
|
||||
extensionDetected: false,
|
||||
configPushed: false,
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function passphraseStrength(pw: string): 'weak' | 'fair' | 'good' | 'strong' {
|
||||
let score = 0;
|
||||
if (pw.length >= 8) score++;
|
||||
if (pw.length >= 14) score++;
|
||||
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
|
||||
if (/[0-9]/.test(pw)) score++;
|
||||
if (/[^a-zA-Z0-9]/.test(pw)) score++;
|
||||
if (score <= 1) return 'weak';
|
||||
if (score <= 2) return 'fair';
|
||||
if (score <= 3) return 'good';
|
||||
return 'strong';
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
|
||||
function render(): void {
|
||||
const app = document.getElementById('app');
|
||||
if (!app) return;
|
||||
|
||||
const progressHtml = `
|
||||
<div class="progress-bar">
|
||||
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
|
||||
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let stepHtml = '';
|
||||
switch (state.step) {
|
||||
case 1: stepHtml = renderStep1(); break;
|
||||
case 2: stepHtml = renderStep2(); break;
|
||||
case 3: stepHtml = renderStep3(); break;
|
||||
case 4: stepHtml = renderStep4(); break;
|
||||
}
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad" style="padding-top:12px;">
|
||||
<div class="brand" style="margin-bottom:4px;">idfoto vault setup</div>
|
||||
${progressHtml}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
${stepHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
switch (state.step) {
|
||||
case 1: attachStep1(); break;
|
||||
case 2: attachStep2(); break;
|
||||
case 3: attachStep3(); break;
|
||||
case 4: attachStep4(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 1: Choose Host ---
|
||||
|
||||
function renderStep1(): string {
|
||||
const giteaInstructions = `
|
||||
<div class="step-instructions">
|
||||
<ol>
|
||||
<li>Create a new <strong>private</strong> repository on your Gitea instance (e.g. <code>vault</code>)</li>
|
||||
<li>Go to <strong>Settings → Applications</strong></li>
|
||||
<li>Generate a new token with <code>repo</code> (read/write) permission</li>
|
||||
<li>Copy the token — you will need it in the next step</li>
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const githubInstructions = `
|
||||
<div class="step-instructions">
|
||||
<ol>
|
||||
<li>Create a new <strong>private</strong> repository on GitHub (e.g. <code>vault</code>)</li>
|
||||
<li>Go to <strong>Settings → Developer settings → Personal access tokens → Fine-grained tokens</strong></li>
|
||||
<li>Generate a new token scoped to the vault repo with <strong>Contents</strong> read/write permission</li>
|
||||
<li>Copy the token — you will need it in the next step</li>
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<h3>choose host</h3>
|
||||
<div class="form-group">
|
||||
<label class="label">host type</label>
|
||||
<div class="toggle-group">
|
||||
<button class="${state.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">Gitea</button>
|
||||
<button class="${state.hostType === 'github' ? 'active' : ''}" data-host="github">GitHub</button>
|
||||
</div>
|
||||
</div>
|
||||
${state.hostType === 'gitea' ? giteaInstructions : githubInstructions}
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" id="next-btn">next</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function attachStep1(): void {
|
||||
document.querySelectorAll('.toggle-group button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
|
||||
state.connectionTested = false;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('next-btn')?.addEventListener('click', () => {
|
||||
state.step = 2;
|
||||
state.error = null;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Step 2: Configure Connection ---
|
||||
|
||||
function renderStep2(): string {
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<h3>configure connection</h3>
|
||||
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
|
||||
<label class="label" for="host-url">host url</label>
|
||||
<input id="host-url" type="text" value="${escapeHtml(state.hostUrl)}" placeholder="https://git.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="repo-path">repository path</label>
|
||||
<input id="repo-path" type="text" value="${escapeHtml(state.repoPath)}" placeholder="user/vault">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="api-token">api token</label>
|
||||
<input id="api-token" type="password" value="${escapeHtml(state.apiToken)}" placeholder="paste your token here">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="test-btn">test connection</button>
|
||||
${state.connectionTested ? '<span class="test-result pass">connected</span>' : ''}
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:12px;">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="next-btn" ${!state.connectionTested ? 'disabled' : ''}>next</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function attachStep2(): void {
|
||||
document.getElementById('test-btn')?.addEventListener('click', async () => {
|
||||
const hostUrl = state.hostType === 'github'
|
||||
? 'https://api.github.com'
|
||||
: (document.getElementById('host-url') as HTMLInputElement).value.trim();
|
||||
const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim();
|
||||
const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim();
|
||||
|
||||
if (!repoPath || !apiToken) {
|
||||
state.error = 'Repository path and API token are required';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (state.hostType === 'gitea' && !hostUrl) {
|
||||
state.error = 'Host URL is required for Gitea';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
state.hostUrl = hostUrl;
|
||||
state.repoPath = repoPath;
|
||||
state.apiToken = apiToken;
|
||||
|
||||
try {
|
||||
const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken);
|
||||
await host.listDir('');
|
||||
state.connectionTested = true;
|
||||
state.error = null;
|
||||
} catch (err: unknown) {
|
||||
state.connectionTested = false;
|
||||
state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
state.step = 1;
|
||||
state.error = null;
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('next-btn')?.addEventListener('click', () => {
|
||||
if (!state.connectionTested) return;
|
||||
state.step = 3;
|
||||
state.error = null;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Step 3: Create Vault ---
|
||||
|
||||
function renderStep3(): string {
|
||||
const strength = state.passphrase ? passphraseStrength(state.passphrase) : null;
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<h3>create vault</h3>
|
||||
<div class="form-group">
|
||||
<label class="label">carrier image (JPEG)</label>
|
||||
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
|
||||
<input type="file" id="file-input" accept="image/jpeg" style="display:none;">
|
||||
${state.carrierImageBytes
|
||||
? '<p class="secondary">image loaded</p>'
|
||||
: '<p class="secondary">click to select a JPEG photo</p>'}
|
||||
</div>
|
||||
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase">passphrase</label>
|
||||
<input id="passphrase" type="password" value="${escapeHtml(state.passphrase)}" placeholder="enter a strong passphrase">
|
||||
${strength ? `
|
||||
<div class="strength-bar">
|
||||
<div class="strength-bar-fill ${strength}"></div>
|
||||
</div>
|
||||
<p class="muted" style="margin-top:2px;">strength: ${strength}</p>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="passphrase-confirm">confirm passphrase</label>
|
||||
<input id="passphrase-confirm" type="password" value="${escapeHtml(state.passphraseConfirm)}" placeholder="re-enter passphrase">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="back-btn">back</button>
|
||||
<button class="btn btn-primary" id="create-btn" ${state.creating ? 'disabled' : ''}>
|
||||
${state.creating ? '<span class="spinner"></span> creating...' : 'create vault'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function attachStep3(): void {
|
||||
const fileDrop = document.getElementById('file-drop')!;
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
|
||||
fileDrop.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer);
|
||||
state.error = null;
|
||||
render();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
// Track passphrase changes without full re-render
|
||||
document.getElementById('passphrase')?.addEventListener('input', (e) => {
|
||||
state.passphrase = (e.target as HTMLInputElement).value;
|
||||
// Update strength bar inline
|
||||
const strength = passphraseStrength(state.passphrase);
|
||||
const bar = document.querySelector('.strength-bar-fill') as HTMLElement | null;
|
||||
const label = document.querySelector('.strength-bar + .muted') as HTMLElement | null;
|
||||
if (bar) {
|
||||
bar.className = `strength-bar-fill ${strength}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = `strength: ${strength}`;
|
||||
}
|
||||
if (!bar && state.passphrase) {
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
|
||||
state.passphraseConfirm = (e.target as HTMLInputElement).value;
|
||||
});
|
||||
|
||||
document.getElementById('back-btn')?.addEventListener('click', () => {
|
||||
state.step = 2;
|
||||
state.error = null;
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('create-btn')?.addEventListener('click', async () => {
|
||||
// Read current values from DOM
|
||||
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
|
||||
state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value;
|
||||
|
||||
if (!state.carrierImageBytes) {
|
||||
state.error = 'Please select a carrier JPEG image';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!state.passphrase) {
|
||||
state.error = 'Passphrase is required';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (state.passphrase !== state.passphraseConfirm) {
|
||||
state.error = 'Passphrases do not match';
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
state.creating = true;
|
||||
state.error = null;
|
||||
render();
|
||||
|
||||
try {
|
||||
const w = await loadWasm();
|
||||
|
||||
// 1. Generate 32-byte image secret
|
||||
const imageSecret = new Uint8Array(32);
|
||||
crypto.getRandomValues(imageSecret);
|
||||
|
||||
// 2. Embed secret into carrier JPEG
|
||||
state.referenceImageBytes = new Uint8Array(
|
||||
w.embed_image_secret(state.carrierImageBytes, imageSecret)
|
||||
);
|
||||
|
||||
// 3. Generate 32-byte salt
|
||||
const salt = new Uint8Array(32);
|
||||
crypto.getRandomValues(salt);
|
||||
|
||||
// 4. Create KDF params
|
||||
const paramsJson = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
|
||||
|
||||
// 5. Derive master key
|
||||
const masterKey = w.derive_master_key(
|
||||
state.passphrase,
|
||||
imageSecret,
|
||||
salt,
|
||||
paramsJson,
|
||||
);
|
||||
|
||||
// 6. Encrypt empty manifest
|
||||
const manifestJson = '{"entries":{},"version":1}';
|
||||
const encryptedManifest = w.encrypt_manifest(manifestJson, masterKey);
|
||||
|
||||
// 7. Push vault files via git API
|
||||
const hostUrl = state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl;
|
||||
const host = createGitHost(state.hostType, hostUrl, state.repoPath, state.apiToken);
|
||||
|
||||
await host.writeFile(
|
||||
'.idfoto/salt',
|
||||
salt,
|
||||
'init: vault salt',
|
||||
);
|
||||
|
||||
const paramsBytes = new TextEncoder().encode(paramsJson);
|
||||
await host.writeFile(
|
||||
'.idfoto/params.json',
|
||||
paramsBytes,
|
||||
'init: KDF parameters',
|
||||
);
|
||||
|
||||
const devicesJson = '{"devices":[]}';
|
||||
const devicesBytes = new TextEncoder().encode(devicesJson);
|
||||
await host.writeFile(
|
||||
'.idfoto/devices.json',
|
||||
devicesBytes,
|
||||
'init: device registry',
|
||||
);
|
||||
|
||||
await host.writeFile(
|
||||
'manifest.enc',
|
||||
new Uint8Array(encryptedManifest),
|
||||
'init: encrypted manifest',
|
||||
);
|
||||
|
||||
// 8. Advance to step 4
|
||||
state.creating = false;
|
||||
state.step = 4;
|
||||
state.error = null;
|
||||
|
||||
// Detect extension
|
||||
detectExtension();
|
||||
|
||||
render();
|
||||
} catch (err: unknown) {
|
||||
state.creating = false;
|
||||
state.error = `Vault creation failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Step 4: Finish ---
|
||||
|
||||
function detectExtension(): void {
|
||||
try {
|
||||
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) {
|
||||
// Try to ping the extension
|
||||
chrome.runtime.sendMessage({ type: 'is_unlocked' }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
state.extensionDetected = false;
|
||||
} else {
|
||||
state.extensionDetected = true;
|
||||
}
|
||||
render();
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
state.extensionDetected = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStep4(): string {
|
||||
const config: VaultConfig = {
|
||||
hostType: state.hostType,
|
||||
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
|
||||
repoPath: state.repoPath,
|
||||
apiToken: state.apiToken,
|
||||
};
|
||||
|
||||
const configJson = JSON.stringify(config, null, 2);
|
||||
|
||||
return `
|
||||
<div class="wizard-step">
|
||||
<div class="success-box">
|
||||
<h3>vault created</h3>
|
||||
<p class="secondary">Your vault has been initialized and pushed to the repository.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label">reference image</label>
|
||||
<p class="muted" style="margin-bottom:8px;">
|
||||
Download and store this image securely. It is your second factor for decryption.
|
||||
Without it, you cannot unlock the vault.
|
||||
</p>
|
||||
<button class="btn btn-primary" id="download-ref-btn">download reference.jpg</button>
|
||||
</div>
|
||||
|
||||
${state.extensionDetected ? `
|
||||
<div class="form-group" style="margin-top:16px;">
|
||||
<label class="label">extension configuration</label>
|
||||
<button class="btn btn-primary" id="push-config-btn" ${state.configPushed ? 'disabled' : ''}>
|
||||
${state.configPushed ? 'config saved to extension' : 'save config to extension'}
|
||||
</button>
|
||||
${state.configPushed ? '<span class="test-result pass" style="margin-left:8px;">saved</span>' : ''}
|
||||
</div>
|
||||
` : `
|
||||
<div class="form-group" style="margin-top:16px;">
|
||||
<label class="label">extension configuration</label>
|
||||
<p class="muted" style="margin-bottom:8px;">
|
||||
Copy this JSON and paste it into the extension setup, or save it for later.
|
||||
</p>
|
||||
<div class="config-blob" id="config-blob">${escapeHtml(configJson)}</div>
|
||||
<button class="btn" id="copy-config-btn">copy to clipboard</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function attachStep4(): void {
|
||||
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
|
||||
if (!state.referenceImageBytes) return;
|
||||
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'reference.jpg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
document.getElementById('push-config-btn')?.addEventListener('click', async () => {
|
||||
if (!state.referenceImageBytes) return;
|
||||
|
||||
const config: VaultConfig = {
|
||||
hostType: state.hostType,
|
||||
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
|
||||
repoPath: state.repoPath,
|
||||
apiToken: state.apiToken,
|
||||
};
|
||||
|
||||
const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes);
|
||||
|
||||
try {
|
||||
chrome.runtime.sendMessage(
|
||||
{ type: 'save_setup', config, imageBase64 },
|
||||
(response: { ok: boolean; error?: string }) => {
|
||||
if (response?.ok) {
|
||||
state.configPushed = true;
|
||||
} else {
|
||||
state.error = response?.error ?? 'Failed to save config to extension';
|
||||
}
|
||||
render();
|
||||
},
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
state.error = `Failed to communicate with extension: ${err instanceof Error ? err.message : String(err)}`;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('copy-config-btn')?.addEventListener('click', async () => {
|
||||
const blob = document.getElementById('config-blob');
|
||||
if (!blob) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(blob.textContent ?? '');
|
||||
const btn = document.getElementById('copy-config-btn')!;
|
||||
btn.textContent = 'copied!';
|
||||
setTimeout(() => { btn.textContent = 'copy to clipboard'; }, 2000);
|
||||
} catch {
|
||||
// Fallback: select the text
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(blob);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Boot ---
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
render();
|
||||
});
|
||||
Reference in New Issue
Block a user