Add user onboarding, keyboard overlay, and DB support
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.
This commit is contained in:
Generated
+12
-1
@@ -8,7 +8,8 @@
|
||||
"name": "nebula-os",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@nebulaproject/core": "^0.1.3"
|
||||
"@nebulaproject/core": "^0.1.3",
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
@@ -20,6 +21,16 @@
|
||||
"integrity": "sha512-sOjH10J1qSyqRNNi0yM3GAhkQk6lLGgmPPoyEljGfPC2Ty3iBoY44ML0sfepiiedVtedbkeDPpDcyx/UbVis6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@
|
||||
"@tauri-apps/cli": "^2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nebulaproject/core": "^0.1.3"
|
||||
"@nebulaproject/core": "^0.1.3",
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+74
@@ -8,6 +8,18 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -866,6 +878,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1369,6 +1393,15 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1384,6 +1417,15 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -1886,6 +1928,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -2047,6 +2100,7 @@ dependencies = [
|
||||
name = "nebula-os"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -2940,6 +2994,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -4212,6 +4280,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -22,4 +22,5 @@ tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
|
||||
+201
-4
@@ -1,14 +1,211 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::Manager;
|
||||
|
||||
struct DbState {
|
||||
db_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UserRecord {
|
||||
id: i64,
|
||||
name: String,
|
||||
first_name: String,
|
||||
last_name: Option<String>,
|
||||
created_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
impl DbState {
|
||||
fn connect(&self) -> Result<Connection, String> {
|
||||
Connection::open(&self.db_path).map_err(|err| format!("Failed to open database: {err}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> Result<String, String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("User name cannot be empty.".to_string());
|
||||
}
|
||||
if trimmed.chars().count() > 64 {
|
||||
return Err("User name is too long (max 64 chars).".to_string());
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn sanitize_optional_name(name: Option<String>) -> Result<Option<String>, String> {
|
||||
let Some(value) = name else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.chars().count() > 64 {
|
||||
return Err("Name is too long (max 64 chars).".to_string());
|
||||
}
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
fn build_display_name(first_name: &str, last_name: Option<&str>) -> String {
|
||||
let last = last_name.map(str::trim).unwrap_or("");
|
||||
if last.is_empty() {
|
||||
first_name.to_string()
|
||||
} else {
|
||||
format!("{first_name} {last}")
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_users_schema(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at_unix_ms INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|err| format!("Failed to prepare users table: {err}"))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("PRAGMA table_info(users)")
|
||||
.map_err(|err| format!("Failed to inspect users schema: {err}"))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|err| format!("Failed to read users schema: {err}"))?;
|
||||
|
||||
let mut has_first_name = false;
|
||||
let mut has_last_name = false;
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|err| format!("Failed to iterate users schema: {err}"))?
|
||||
{
|
||||
let column_name: String = row
|
||||
.get(1)
|
||||
.map_err(|err| format!("Failed to parse users schema: {err}"))?;
|
||||
if column_name == "first_name" {
|
||||
has_first_name = true;
|
||||
}
|
||||
if column_name == "last_name" {
|
||||
has_last_name = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !has_first_name {
|
||||
conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT", [])
|
||||
.map_err(|err| format!("Failed to add first_name column: {err}"))?;
|
||||
conn.execute(
|
||||
"UPDATE users SET first_name = TRIM(name) WHERE first_name IS NULL OR TRIM(first_name) = ''",
|
||||
[],
|
||||
)
|
||||
.map_err(|err| format!("Failed to backfill first_name: {err}"))?;
|
||||
}
|
||||
|
||||
if !has_last_name {
|
||||
conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT", [])
|
||||
.map_err(|err| format!("Failed to add last_name column: {err}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
fn get_first_user(state: tauri::State<'_, DbState>) -> Result<Option<UserRecord>, String> {
|
||||
let conn = state.connect()?;
|
||||
ensure_users_schema(&conn)?;
|
||||
conn.query_row(
|
||||
"SELECT
|
||||
id,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(COALESCE(first_name, '') || ' ' || COALESCE(last_name, '')), ''),
|
||||
name
|
||||
) AS display_name,
|
||||
COALESCE(NULLIF(TRIM(first_name), ''), name) AS first_name,
|
||||
NULLIF(TRIM(COALESCE(last_name, '')), '') AS last_name,
|
||||
created_at_unix_ms
|
||||
FROM users
|
||||
ORDER BY id ASC
|
||||
LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(UserRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
first_name: row.get(2)?,
|
||||
last_name: row.get(3)?,
|
||||
created_at_unix_ms: row.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| format!("Failed to load user: {err}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_user(
|
||||
first_name: String,
|
||||
last_name: Option<String>,
|
||||
state: tauri::State<'_, DbState>,
|
||||
) -> Result<UserRecord, String> {
|
||||
let safe_first_name = sanitize_name(&first_name)?;
|
||||
let safe_last_name = sanitize_optional_name(last_name)?;
|
||||
let display_name = build_display_name(&safe_first_name, safe_last_name.as_deref());
|
||||
let created_at_unix_ms = now_unix_ms();
|
||||
let conn = state.connect()?;
|
||||
ensure_users_schema(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO users (name, first_name, last_name, created_at_unix_ms) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![
|
||||
display_name,
|
||||
safe_first_name,
|
||||
safe_last_name,
|
||||
created_at_unix_ms
|
||||
],
|
||||
)
|
||||
.map_err(|err| format!("Failed to create user: {err}"))?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(UserRecord {
|
||||
id,
|
||||
name: build_display_name(&safe_first_name, safe_last_name.as_deref()),
|
||||
first_name: safe_first_name,
|
||||
last_name: safe_last_name,
|
||||
created_at_unix_ms,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.setup(|app| {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|err| format!("Failed to resolve app data dir: {err}"))?;
|
||||
fs::create_dir_all(&app_data_dir)
|
||||
.map_err(|err| format!("Failed to create app data dir: {err}"))?;
|
||||
|
||||
let db_path = app_data_dir.join("nebula.db");
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|err| format!("Failed to initialize database: {err}"))?;
|
||||
ensure_users_schema(&conn)?;
|
||||
drop(conn);
|
||||
|
||||
app.manage(DbState { db_path });
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![get_first_user, create_user])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPasskeyController } from "./passkey.js";
|
||||
import { loadExistingUser } from "./users.js";
|
||||
|
||||
const FALLBACK_THEME = {
|
||||
colors: {
|
||||
@@ -53,6 +54,9 @@ export const createAppState = () => {
|
||||
passkey,
|
||||
locked: true,
|
||||
activeView: "lock",
|
||||
user: null,
|
||||
profileName: "Nebula User",
|
||||
userSetupRequired: true,
|
||||
nebula: {
|
||||
coreReady: false,
|
||||
source: "local-fallback",
|
||||
@@ -136,6 +140,14 @@ export const createAppState = () => {
|
||||
applyThemeToDocument();
|
||||
};
|
||||
|
||||
const initializeUser = async () => {
|
||||
const user = await loadExistingUser();
|
||||
state.user = user;
|
||||
state.userSetupRequired = !user;
|
||||
state.profileName = user?.name ?? "New User";
|
||||
};
|
||||
|
||||
state.initializeNebulaCore = initializeNebulaCore;
|
||||
state.initializeUser = initializeUser;
|
||||
return state;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
const LEGACY_USER_STORAGE_KEY = "nebula.user.v1";
|
||||
|
||||
const getInvoke = async () => {
|
||||
try {
|
||||
const tauriCore = await import("@tauri-apps/api/core");
|
||||
return typeof tauriCore.invoke === "function" ? tauriCore.invoke : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeUser = (record) => {
|
||||
if (!record || typeof record !== "object") {
|
||||
return null;
|
||||
}
|
||||
const id = Number(record.id);
|
||||
const name = typeof record.name === "string" ? record.name.trim() : "";
|
||||
const firstName = typeof record.firstName === "string" ? record.firstName.trim() : "";
|
||||
const lastName = typeof record.lastName === "string" ? record.lastName.trim() : "";
|
||||
if (!Number.isFinite(id) || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
firstName: firstName || name,
|
||||
lastName: lastName || "",
|
||||
createdAtUnixMs: Number(record.createdAtUnixMs ?? Date.now()),
|
||||
};
|
||||
};
|
||||
|
||||
export const loadExistingUser = async () => {
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
const legacyRaw = window.localStorage.getItem(LEGACY_USER_STORAGE_KEY);
|
||||
if (!legacyRaw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeUser(JSON.parse(legacyRaw));
|
||||
} catch (_parseError) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await invoke("get_first_user");
|
||||
return normalizeUser(record);
|
||||
} catch (_error) {
|
||||
const legacyRaw = window.localStorage.getItem(LEGACY_USER_STORAGE_KEY);
|
||||
if (!legacyRaw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeUser(JSON.parse(legacyRaw));
|
||||
} catch (_parseError) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createUser = async ({ firstName, lastName = "" }) => {
|
||||
const requestedFirstName = String(firstName ?? "").trim();
|
||||
const requestedLastName = String(lastName ?? "").trim();
|
||||
if (!requestedFirstName) {
|
||||
throw new Error("First name is required.");
|
||||
}
|
||||
|
||||
const invoke = await getInvoke();
|
||||
if (!invoke) {
|
||||
const fallback = {
|
||||
id: 1,
|
||||
name: requestedLastName ? `${requestedFirstName} ${requestedLastName}` : requestedFirstName,
|
||||
firstName: requestedFirstName,
|
||||
lastName: requestedLastName,
|
||||
createdAtUnixMs: Date.now(),
|
||||
};
|
||||
window.localStorage.setItem(LEGACY_USER_STORAGE_KEY, JSON.stringify(fallback));
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await invoke("create_user", {
|
||||
firstName: requestedFirstName,
|
||||
lastName: requestedLastName || null,
|
||||
});
|
||||
return normalizeUser(record);
|
||||
} catch (_error) {
|
||||
const fallback = {
|
||||
id: 1,
|
||||
name: requestedLastName ? `${requestedFirstName} ${requestedLastName}` : requestedFirstName,
|
||||
firstName: requestedFirstName,
|
||||
lastName: requestedLastName,
|
||||
createdAtUnixMs: Date.now(),
|
||||
};
|
||||
window.localStorage.setItem(LEGACY_USER_STORAGE_KEY, JSON.stringify(fallback));
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
@@ -6,10 +6,12 @@
|
||||
<link rel="stylesheet" href="/styles/base.css" />
|
||||
<link rel="stylesheet" href="/styles/components.css" />
|
||||
<link rel="stylesheet" href="/views/lock/lock.css" />
|
||||
<link rel="stylesheet" href="/views/onboarding/userSetup.css" />
|
||||
<link rel="stylesheet" href="/views/home/home.css" />
|
||||
<link rel="stylesheet" href="/views/settings/settings.css" />
|
||||
<link rel="stylesheet" href="/views/library/library.css" />
|
||||
<link rel="stylesheet" href="/views/overlays/powerMenu.css" />
|
||||
<link rel="stylesheet" href="/views/overlays/keyboard.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nebula Shell</title>
|
||||
<script type="module" src="/main.js" defer></script>
|
||||
@@ -27,6 +29,7 @@
|
||||
</div>
|
||||
<main id="app" class="app-shell"></main>
|
||||
<div id="overlay-root"></div>
|
||||
<div id="keyboard-root"></div>
|
||||
<footer class="app-footer" id="app-footer"></footer>
|
||||
|
||||
<template id="global-hints-template">
|
||||
@@ -43,5 +46,14 @@
|
||||
<span class="hint"><span data-glyph="menu"></span> Power Menu</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="keyboard-hints-template">
|
||||
<div class="hint-row">
|
||||
<span class="hint"><span data-glyph="accept"></span> Type</span>
|
||||
<span class="hint"><span data-glyph="back"></span> Backspace</span>
|
||||
<span class="hint"><span data-glyph="l1"></span>/<span data-glyph="r1"></span> Field</span>
|
||||
<span class="hint"><span data-glyph="menu"></span> Done</span>
|
||||
</div>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+15
-2
@@ -5,17 +5,21 @@ import { createAppState } from "./core/state.js";
|
||||
import { createHomeView } from "./views/home/home.js";
|
||||
import { createLibraryView } from "./views/library/library.js";
|
||||
import { createLockView } from "./views/lock/lock.js";
|
||||
import { createUserSetupView } from "./views/onboarding/userSetup.js";
|
||||
import { createKeyboardOverlay } from "./views/overlays/keyboard.js";
|
||||
import { createPowerMenuOverlay } from "./views/overlays/powerMenu.js";
|
||||
import { createSettingsView } from "./views/settings/settings.js";
|
||||
|
||||
const appRoot = document.querySelector("#app");
|
||||
const overlayRoot = document.querySelector("#overlay-root");
|
||||
const keyboardRoot = document.querySelector("#keyboard-root");
|
||||
const footer = document.querySelector("#app-footer");
|
||||
|
||||
const state = createAppState();
|
||||
const nav = createNavigationManager();
|
||||
const router = createRouter(appRoot);
|
||||
const powerMenu = createPowerMenuOverlay({ mountRoot: overlayRoot, state });
|
||||
const keyboard = createKeyboardOverlay({ mountRoot: keyboardRoot });
|
||||
|
||||
let currentViewContract = null;
|
||||
|
||||
@@ -72,7 +76,8 @@ const renderView = (viewId) => {
|
||||
};
|
||||
|
||||
const registerViews = () => {
|
||||
const context = { state, renderView, powerMenu, openPowerMenu };
|
||||
const context = { state, renderView, powerMenu, keyboard, openPowerMenu };
|
||||
router.register(createUserSetupView(context));
|
||||
router.register(createLockView(context));
|
||||
router.register(createHomeView(context));
|
||||
router.register(createSettingsView(context));
|
||||
@@ -94,6 +99,13 @@ const handleAction = (action) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyboard.isOpen()) {
|
||||
const consumed = keyboard.handleAction(action);
|
||||
if (consumed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentViewContract) {
|
||||
return;
|
||||
}
|
||||
@@ -138,9 +150,10 @@ const handleAction = (action) => {
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
await state.initializeUser();
|
||||
await state.initializeNebulaCore();
|
||||
registerViews();
|
||||
renderView("lock");
|
||||
renderView(state.userSetupRequired ? "user-setup" : "lock");
|
||||
updateClockLabels();
|
||||
window.setInterval(updateClockLabels, 1000);
|
||||
|
||||
|
||||
@@ -74,6 +74,55 @@
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.lock-user-setup {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.lock-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lock-field span {
|
||||
font-size: 13px;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.lock-field input {
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(9, 15, 30, 0.65);
|
||||
color: var(--nebula-color-text);
|
||||
padding: 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.lock-field input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(79, 216, 255, 0.72);
|
||||
box-shadow: 0 0 0 2px rgba(79, 216, 255, 0.2);
|
||||
}
|
||||
|
||||
.lock-create-user {
|
||||
height: 46px;
|
||||
border: 1px solid rgba(79, 216, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(160deg, rgba(51, 87, 135, 0.55), rgba(18, 33, 58, 0.8));
|
||||
color: var(--nebula-color-text);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.lock-create-user.is-focused {
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(79, 216, 255, 0.55),
|
||||
0 0 24px rgba(79, 216, 255, 0.3);
|
||||
}
|
||||
|
||||
.lock-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+23
-5
@@ -31,12 +31,13 @@ const LOCK_TEMPLATE = `
|
||||
</section>
|
||||
`;
|
||||
|
||||
export const createLockView = ({ state, renderView }) => {
|
||||
export const createLockView = ({ state, renderView, keyboard }) => {
|
||||
const ENTRY_DEBOUNCE_MS = 120;
|
||||
const FAIL_CLEAR_MS = 600;
|
||||
|
||||
let digits = [];
|
||||
let setupDigits = [];
|
||||
let setupSeed = "";
|
||||
let setupPhase = "create";
|
||||
let busy = false;
|
||||
let lastEntryAt = 0;
|
||||
@@ -106,7 +107,7 @@ export const createLockView = ({ state, renderView }) => {
|
||||
const length = config().length;
|
||||
if (state.passkeySetupRequired) {
|
||||
copy.textContent = setupPhase === "confirm"
|
||||
? `Re-enter the same ${length}-digit passkey to confirm.`
|
||||
? `Re-enter the same ${length}-digit passkey, then press Start to confirm.`
|
||||
: `Use Xbox passkey controls to enter your ${length}-digit passkey.`;
|
||||
return;
|
||||
}
|
||||
@@ -203,18 +204,24 @@ export const createLockView = ({ state, renderView }) => {
|
||||
if (state.passkeySetupRequired) {
|
||||
if (setupPhase === "create") {
|
||||
setupDigits = [...digits];
|
||||
setupSeed = setupDigits.join("|");
|
||||
clearDigits();
|
||||
setupPhase = "confirm";
|
||||
updateCopy();
|
||||
setStatus("Confirm your new passkey.");
|
||||
setStatus("Re-enter your passkey and press Start.");
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const same = setupDigits.length === digits.length && setupDigits.every((digit, index) => digit === digits[index]);
|
||||
const confirmSeed = digits.join("|");
|
||||
const same = setupSeed.length > 0
|
||||
&& setupDigits.length === digits.length
|
||||
&& setupDigits.every((digit, index) => digit === digits[index])
|
||||
&& confirmSeed === setupSeed;
|
||||
if (!same) {
|
||||
setupPhase = "create";
|
||||
setupDigits = [];
|
||||
setupSeed = "";
|
||||
updateCopy();
|
||||
applyFailure("Passkeys did not match. Try again.");
|
||||
busy = false;
|
||||
@@ -280,7 +287,11 @@ export const createLockView = ({ state, renderView }) => {
|
||||
pulseHaptic(8);
|
||||
|
||||
if (digits.length === config().length && !config().requireConfirm) {
|
||||
submitDigits();
|
||||
if (state.passkeySetupRequired) {
|
||||
setStatus("Press Start to confirm.");
|
||||
} else {
|
||||
submitDigits();
|
||||
}
|
||||
} else if (digits.length === config().length && config().requireConfirm) {
|
||||
setStatus("Press Start to confirm.");
|
||||
}
|
||||
@@ -321,8 +332,15 @@ export const createLockView = ({ state, renderView }) => {
|
||||
id: "lock",
|
||||
render: () => LOCK_TEMPLATE,
|
||||
mount: () => {
|
||||
keyboard?.close?.();
|
||||
if (state.userSetupRequired) {
|
||||
renderView("user-setup");
|
||||
return;
|
||||
}
|
||||
|
||||
setupPhase = "create";
|
||||
setupDigits = [];
|
||||
setupSeed = "";
|
||||
clearDigits();
|
||||
updateCopy();
|
||||
setStatus("");
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
.user-setup-view {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-setup-layout {
|
||||
width: min(760px, 94vw);
|
||||
min-height: min(520px, 66vh);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(24px, 3vw, 38px);
|
||||
}
|
||||
|
||||
.user-setup-main {
|
||||
width: min(560px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-setup-eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.user-setup-title {
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 4vw, 48px);
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.user-setup-copy {
|
||||
margin: 0;
|
||||
color: var(--nebula-color-muted);
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.user-setup-fields {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.user-setup-field {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(11, 19, 36, 0.58);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.user-setup-field.is-active {
|
||||
border-color: rgba(79, 216, 255, 0.7);
|
||||
box-shadow: 0 0 0 1px rgba(79, 216, 255, 0.35);
|
||||
}
|
||||
|
||||
.user-setup-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--nebula-color-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.user-setup-value {
|
||||
margin: 0;
|
||||
min-height: 24px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.user-setup-status {
|
||||
margin: 8px 0 0;
|
||||
min-height: 22px;
|
||||
font-size: 15px;
|
||||
color: var(--nebula-color-muted);
|
||||
}
|
||||
|
||||
.user-setup-status.is-danger {
|
||||
color: var(--nebula-color-danger);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createUser } from "../../core/users.js";
|
||||
|
||||
const USER_SETUP_TEMPLATE = `
|
||||
<section class="view user-setup-view" data-view="user-setup">
|
||||
<section class="user-setup-layout panel">
|
||||
<section class="user-setup-main">
|
||||
<p class="user-setup-eyebrow">First Time Setup</p>
|
||||
<h1 class="user-setup-title">Create Your Profile</h1>
|
||||
<p class="user-setup-copy">Use your controller to enter your first and last name, then press Done.</p>
|
||||
|
||||
<div class="user-setup-fields">
|
||||
<div class="user-setup-field" data-field-card="firstName">
|
||||
<span class="user-setup-label">First Name</span>
|
||||
<p class="user-setup-value" data-field-value="firstName">-</p>
|
||||
</div>
|
||||
<div class="user-setup-field" data-field-card="lastName">
|
||||
<span class="user-setup-label">Last Name (Optional)</span>
|
||||
<p class="user-setup-value" data-field-value="lastName">-</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="user-setup-status" data-user-setup-status></p>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
`;
|
||||
|
||||
export const createUserSetupView = ({ state, renderView, keyboard }) => {
|
||||
const model = {
|
||||
activeField: "firstName",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
busy: false,
|
||||
};
|
||||
|
||||
const setStatus = (text = "", danger = false) => {
|
||||
const status = document.querySelector("[data-user-setup-status]");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.textContent = text;
|
||||
status.classList.toggle("is-danger", danger);
|
||||
};
|
||||
|
||||
const renderFields = () => {
|
||||
const firstValue = document.querySelector("[data-field-value='firstName']");
|
||||
const lastValue = document.querySelector("[data-field-value='lastName']");
|
||||
if (firstValue) {
|
||||
firstValue.textContent = model.firstName || "-";
|
||||
}
|
||||
if (lastValue) {
|
||||
lastValue.textContent = model.lastName || "-";
|
||||
}
|
||||
|
||||
const firstCard = document.querySelector("[data-field-card='firstName']");
|
||||
const lastCard = document.querySelector("[data-field-card='lastName']");
|
||||
firstCard?.classList.toggle("is-active", model.activeField === "firstName");
|
||||
lastCard?.classList.toggle("is-active", model.activeField === "lastName");
|
||||
};
|
||||
|
||||
const appendCharacter = (character) => {
|
||||
const field = model.activeField;
|
||||
const maxLength = 20;
|
||||
if (character === " ") {
|
||||
if (!model[field]) {
|
||||
return;
|
||||
}
|
||||
if (model[field].length >= maxLength) {
|
||||
return;
|
||||
}
|
||||
model[field] += " ";
|
||||
return;
|
||||
}
|
||||
if (model[field].length >= maxLength) {
|
||||
return;
|
||||
}
|
||||
model[field] += character;
|
||||
};
|
||||
|
||||
const backspaceCharacter = () => {
|
||||
const field = model.activeField;
|
||||
if (!model[field]) {
|
||||
return;
|
||||
}
|
||||
model[field] = model[field].slice(0, -1);
|
||||
};
|
||||
|
||||
const clearField = () => {
|
||||
model[model.activeField] = "";
|
||||
};
|
||||
|
||||
const saveUser = async () => {
|
||||
if (model.busy) {
|
||||
return;
|
||||
}
|
||||
const firstName = model.firstName.trim();
|
||||
const lastName = model.lastName.trim();
|
||||
if (!firstName) {
|
||||
setStatus("First name is required.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
model.busy = true;
|
||||
setStatus("Creating profile...");
|
||||
try {
|
||||
const user = await createUser({ firstName, lastName });
|
||||
if (!user) {
|
||||
throw new Error("Could not create user.");
|
||||
}
|
||||
state.user = user;
|
||||
state.profileName = user.name;
|
||||
state.userSetupRequired = false;
|
||||
// New profile creation should always start with a fresh passkey setup.
|
||||
// This avoids inheriting stale passkey state from previous local data.
|
||||
state.passkey.resetSequence();
|
||||
state.passkeySetupRequired = true;
|
||||
state.activeView = "lock";
|
||||
keyboard.close();
|
||||
renderView("lock");
|
||||
} catch (error) {
|
||||
setStatus(error?.message ?? "Failed to create user.", true);
|
||||
} finally {
|
||||
model.busy = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectField = (field) => {
|
||||
model.activeField = field;
|
||||
renderFields();
|
||||
};
|
||||
|
||||
const nextField = () => {
|
||||
selectField(model.activeField === "firstName" ? "lastName" : "firstName");
|
||||
setStatus(`Editing ${model.activeField === "firstName" ? "First Name" : "Last Name"}.`);
|
||||
};
|
||||
|
||||
const prevField = () => {
|
||||
nextField();
|
||||
};
|
||||
|
||||
return {
|
||||
id: "user-setup",
|
||||
render: () => USER_SETUP_TEMPLATE,
|
||||
mount: () => {
|
||||
if (!state.userSetupRequired) {
|
||||
keyboard.close();
|
||||
renderView("lock");
|
||||
return;
|
||||
}
|
||||
model.activeField = "firstName";
|
||||
model.firstName = "";
|
||||
model.lastName = "";
|
||||
model.busy = false;
|
||||
renderFields();
|
||||
keyboard.open({
|
||||
onKey: (key) => {
|
||||
appendCharacter(key);
|
||||
renderFields();
|
||||
setStatus("");
|
||||
},
|
||||
onBackspace: () => {
|
||||
backspaceCharacter();
|
||||
renderFields();
|
||||
setStatus("");
|
||||
},
|
||||
onClear: () => {
|
||||
clearField();
|
||||
renderFields();
|
||||
setStatus("");
|
||||
},
|
||||
onSubmit: () => {
|
||||
saveUser();
|
||||
},
|
||||
onPrevField: () => {
|
||||
prevField();
|
||||
},
|
||||
onNextField: () => {
|
||||
nextField();
|
||||
},
|
||||
});
|
||||
setStatus("Enter first name, then choose Done.");
|
||||
},
|
||||
getNavigationContract: () => {
|
||||
return {
|
||||
focusRoot: null,
|
||||
defaultFocus: null,
|
||||
layout: { type: "grid", cols: 1, rows: 1 },
|
||||
hintsTemplate: "#keyboard-hints-template",
|
||||
nebulaNavigation: state.nebula.navigation,
|
||||
captureDirectionalInput: false,
|
||||
onAccept: () => {},
|
||||
onBack: () => {
|
||||
keyboard.handleAction("back");
|
||||
},
|
||||
onMenu: () => {
|
||||
keyboard.handleAction("menu");
|
||||
return false;
|
||||
},
|
||||
onAction: () => false,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
.overlay-keyboard {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 18;
|
||||
padding: 10px 16px 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay-keyboard[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.overlay-keyboard-inner {
|
||||
width: min(1080px, 98vw);
|
||||
margin: 0 auto;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background:
|
||||
linear-gradient(170deg, rgba(46, 74, 118, 0.46), rgba(14, 25, 43, 0.88)),
|
||||
rgba(10, 17, 30, 0.9);
|
||||
box-shadow:
|
||||
0 16px 30px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.overlay-keyboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, minmax(44px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key {
|
||||
height: 44px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(160deg, rgba(60, 95, 143, 0.38), rgba(13, 24, 42, 0.84));
|
||||
color: var(--nebula-color-text);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-wide {
|
||||
grid-column: span 4;
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-done {
|
||||
border-color: rgba(122, 255, 168, 0.46);
|
||||
}
|
||||
|
||||
.overlay-keyboard-key.is-focused {
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(79, 216, 255, 0.5),
|
||||
0 0 20px rgba(79, 216, 255, 0.22);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user