f41768c6a9
Introduce first-time user onboarding and a navigable on-screen keyboard, plus persistent user storage via a bundled rusqlite database and Tauri API. Backend: add rusqlite dependency, DB initialization in tauri setup, schema migration/backfill for users, and two Tauri commands (get_first_user, create_user) with input sanitization and structured UserRecord. Frontend: add core/users.js (invoke + localStorage fallback), integrate user state initialization, add user setup view/styles and keyboard overlay (JS/CSS), wire views and navigation to show onboarding when needed, and update lock view behavior to coordinate onboarding and passkey flow. Also add @tauri-apps/api dependency and update package/Cargo lock files accordingly.
176 lines
4.4 KiB
JavaScript
176 lines
4.4 KiB
JavaScript
const KEY_ROWS = [
|
|
["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"],
|
|
["K", "L", "M", "N", "O", "P", "Q", "R", "S", "T"],
|
|
["U", "V", "W", "X", "Y", "Z", "-", "'", ".", ","],
|
|
["SPACE", "BACK", "CLR", "DONE"],
|
|
];
|
|
|
|
const KEYBOARD_TEMPLATE = `
|
|
<section class="overlay-keyboard" data-overlay-keyboard hidden>
|
|
<div class="overlay-keyboard-inner">
|
|
<div class="overlay-keyboard-grid" data-overlay-keyboard-grid></div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
|
|
export const createKeyboardOverlay = ({ mountRoot }) => {
|
|
mountRoot.innerHTML = KEYBOARD_TEMPLATE;
|
|
|
|
const root = mountRoot.querySelector("[data-overlay-keyboard]");
|
|
const grid = mountRoot.querySelector("[data-overlay-keyboard-grid]");
|
|
const keyButtons = [];
|
|
let openState = false;
|
|
let selectedRow = 0;
|
|
let selectedCol = 0;
|
|
let handlers = null;
|
|
|
|
const applySelection = () => {
|
|
keyButtons.forEach((button) => button.classList.remove("is-focused"));
|
|
const selected = keyButtons.find(
|
|
(button) =>
|
|
Number(button.dataset.row) === selectedRow && Number(button.dataset.col) === selectedCol,
|
|
);
|
|
selected?.classList.add("is-focused");
|
|
};
|
|
|
|
const currentKey = () => {
|
|
const selected = keyButtons.find(
|
|
(button) =>
|
|
Number(button.dataset.row) === selectedRow && Number(button.dataset.col) === selectedCol,
|
|
);
|
|
return selected?.dataset.key ?? null;
|
|
};
|
|
|
|
const build = () => {
|
|
if (!grid) {
|
|
return;
|
|
}
|
|
grid.innerHTML = "";
|
|
keyButtons.length = 0;
|
|
|
|
KEY_ROWS.forEach((row, rowIndex) => {
|
|
row.forEach((key, colIndex) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "overlay-keyboard-key";
|
|
button.dataset.row = String(rowIndex);
|
|
button.dataset.col = String(colIndex);
|
|
button.dataset.key = key;
|
|
button.textContent = key === "SPACE" ? "Space" : key;
|
|
if (key === "DONE") {
|
|
button.classList.add("is-done");
|
|
}
|
|
if (key === "SPACE") {
|
|
button.classList.add("is-wide");
|
|
}
|
|
grid.append(button);
|
|
keyButtons.push(button);
|
|
});
|
|
});
|
|
};
|
|
|
|
build();
|
|
|
|
const open = (nextHandlers = {}) => {
|
|
handlers = nextHandlers;
|
|
openState = true;
|
|
selectedRow = 0;
|
|
selectedCol = 0;
|
|
root.hidden = false;
|
|
applySelection();
|
|
};
|
|
|
|
const close = () => {
|
|
openState = false;
|
|
root.hidden = true;
|
|
handlers = null;
|
|
};
|
|
|
|
const move = (direction) => {
|
|
const rowWidth = KEY_ROWS[selectedRow]?.length ?? 0;
|
|
if (direction === "left") {
|
|
selectedCol = clamp(selectedCol - 1, 0, Math.max(0, rowWidth - 1));
|
|
return;
|
|
}
|
|
if (direction === "right") {
|
|
selectedCol = clamp(selectedCol + 1, 0, Math.max(0, rowWidth - 1));
|
|
return;
|
|
}
|
|
if (direction === "up") {
|
|
selectedRow = clamp(selectedRow - 1, 0, KEY_ROWS.length - 1);
|
|
const nextWidth = KEY_ROWS[selectedRow]?.length ?? 0;
|
|
selectedCol = clamp(selectedCol, 0, Math.max(0, nextWidth - 1));
|
|
return;
|
|
}
|
|
if (direction === "down") {
|
|
selectedRow = clamp(selectedRow + 1, 0, KEY_ROWS.length - 1);
|
|
const nextWidth = KEY_ROWS[selectedRow]?.length ?? 0;
|
|
selectedCol = clamp(selectedCol, 0, Math.max(0, nextWidth - 1));
|
|
}
|
|
};
|
|
|
|
const handleAction = (action) => {
|
|
if (!openState) {
|
|
return false;
|
|
}
|
|
|
|
if (action === "up" || action === "down" || action === "left" || action === "right") {
|
|
move(action);
|
|
applySelection();
|
|
return true;
|
|
}
|
|
|
|
if (action === "accept") {
|
|
const key = currentKey();
|
|
if (!key) {
|
|
return true;
|
|
}
|
|
if (key === "DONE") {
|
|
handlers?.onSubmit?.();
|
|
return true;
|
|
}
|
|
if (key === "BACK") {
|
|
handlers?.onBackspace?.();
|
|
return true;
|
|
}
|
|
if (key === "CLR") {
|
|
handlers?.onClear?.();
|
|
return true;
|
|
}
|
|
handlers?.onKey?.(key === "SPACE" ? " " : key);
|
|
return true;
|
|
}
|
|
|
|
if (action === "back") {
|
|
handlers?.onBackspace?.();
|
|
return true;
|
|
}
|
|
|
|
if (action === "menu") {
|
|
handlers?.onSubmit?.();
|
|
return true;
|
|
}
|
|
|
|
if (action === "l1") {
|
|
handlers?.onPrevField?.();
|
|
return true;
|
|
}
|
|
|
|
if (action === "r1") {
|
|
handlers?.onNextField?.();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
return {
|
|
open,
|
|
close,
|
|
isOpen: () => openState,
|
|
handleAction,
|
|
};
|
|
};
|