feat(ui): the app shell: lock screen, sidebar, network switcher, status bar, settings
ci / gate (push) Successful in 9m14s
ci / gate (push) Successful in 9m14s
The frame every screen sits in. A lock screen fronts everything while the session is locked: pick a wallet file, type a password, and the password goes to Rust and nowhere else. Unlocked, a sidebar carries the sections, the network switcher and a lock button with the minutes until the idle rule fires; a status bar shows the chain in use, its endpoint, best and finalized heights and spec version, and says "connecting…" or the last error otherwise. Testnets are labelled in the switcher, the status bar and above their content, as on the observer: a balance on a testnet is not money and the chrome says so before the number does. Settings persist as JSON in the app-data directory, written atomically: network, idle lock, whether testnets are offered. Changing the network re-keys every query; changing the idle lock takes effect on the next check. The theme (system, dark, light) is a local preference applied before first paint from the entry point, because the CSP allows no inline script. Design tokens are the observer's, lifted whole so the wallet and the scoreboard read as one family. The accounts page shows spendable and total per account with the block the numbers were read at and how far that is from final, refreshed by Rust on every head; send, receive and history are placeholders naming their issues. Wide integers now cross the IPC typed as `number`: they are JSON numbers on the wire, which `bigint` cannot be serialised to, and none of them (block heights, seconds, nonces) approaches 2^53. Amounts still travel as strings. Closes #25 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
@@ -10,6 +10,7 @@ use std::time::Instant;
|
||||
|
||||
use wallet_core::{ChainAdapter, SignedBy, TransferIntent, TxStatus};
|
||||
use wallet_data::substrate::SubstrateAdapter;
|
||||
use wallet_entities::Settings;
|
||||
|
||||
use tauri::State;
|
||||
use wallet_core::backup::BackupChallenge;
|
||||
@@ -646,3 +647,40 @@ pub async fn reversible_pending(
|
||||
let adapter = adapter_for(&state, &chain)?;
|
||||
adapter.pending_reversible(me).await.map_err(chain_error)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn settings_get(state: State<'_, AppState>) -> Result<Settings, WalletError> {
|
||||
state
|
||||
.settings
|
||||
.load()
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Persist settings and apply the ones that take effect immediately. A
|
||||
/// network the registry does not know is refused; an idle lock under
|
||||
/// thirty seconds is refused, since it would lock mid-signature.
|
||||
#[tauri::command]
|
||||
pub fn settings_set(
|
||||
state: State<'_, AppState>,
|
||||
settings: Settings,
|
||||
) -> Result<Settings, WalletError> {
|
||||
if state.profiles.get(&settings.network).is_none() {
|
||||
return Err(WalletError::InvalidInput(format!(
|
||||
"unknown network {}",
|
||||
settings.network.0
|
||||
)));
|
||||
}
|
||||
if settings.idle_lock_seconds < 30 {
|
||||
return Err(WalletError::InvalidInput(
|
||||
"the idle lock must be at least 30 seconds".into(),
|
||||
));
|
||||
}
|
||||
state
|
||||
.settings
|
||||
.save(&settings)
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
state
|
||||
.session
|
||||
.set_idle_interval(std::time::Duration::from_secs(settings.idle_lock_seconds));
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ pub fn run() {
|
||||
commands::transfer_discard,
|
||||
commands::reversible_cancel_prepare,
|
||||
commands::reversible_pending,
|
||||
commands::settings_get,
|
||||
commands::settings_set,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("the Tauri runtime failed to start");
|
||||
|
||||
@@ -12,6 +12,7 @@ use wallet_core::backup::BackupChallenge;
|
||||
use wallet_core::profiles::ProfileRegistry;
|
||||
use wallet_core::session::Session;
|
||||
use wallet_data::keystore::FileKeystore;
|
||||
use wallet_data::settings::SettingsStore;
|
||||
use wallet_data::substrate::ChainManager;
|
||||
use wallet_entities::ChainId;
|
||||
use wallet_entities::SignatureScheme;
|
||||
@@ -25,9 +26,6 @@ pub struct PendingCreation {
|
||||
pub scheme: SignatureScheme,
|
||||
}
|
||||
|
||||
/// Until the settings store exists (#25), the idle interval is fixed.
|
||||
pub const DEFAULT_IDLE_LOCK: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
pub struct AppState {
|
||||
pub keystore: FileKeystore,
|
||||
pub pending_creation: Mutex<Option<PendingCreation>>,
|
||||
@@ -44,6 +42,7 @@ pub struct AppState {
|
||||
/// A submit consumes one; a new prepare for the same purpose replaces
|
||||
/// nothing, it just adds, and stale ones are refused by the chain.
|
||||
pub prepared: Mutex<HashMap<String, PreparedTransaction>>,
|
||||
pub settings: SettingsStore,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -65,6 +64,12 @@ impl AppState {
|
||||
None => FileKeystore::default_dir()
|
||||
.map(|d| d.parent().map(|p| p.join("profiles.toml")).unwrap_or(d)),
|
||||
};
|
||||
let settings = SettingsStore::at(
|
||||
FileKeystore::default_dir()
|
||||
.and_then(|d| d.parent().map(|p| p.join("settings.json")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("settings.json")),
|
||||
);
|
||||
let loaded = settings.load()?;
|
||||
let mut profiles = ProfileRegistry::shipped()?;
|
||||
if let Some(path) = override_path {
|
||||
if path.exists() {
|
||||
@@ -76,11 +81,12 @@ impl AppState {
|
||||
Ok(Self {
|
||||
keystore: FileKeystore::in_dir(dir),
|
||||
pending_creation: Mutex::new(None),
|
||||
session: Session::new(DEFAULT_IDLE_LOCK),
|
||||
session: Session::new(Duration::from_secs(loaded.idle_lock_seconds.max(30))),
|
||||
profiles,
|
||||
chains: Mutex::new(HashMap::new()),
|
||||
balance_watchers: Mutex::new(HashMap::new()),
|
||||
prepared: Mutex::new(HashMap::new()),
|
||||
settings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,20 +51,25 @@ pub enum SessionError {
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
idle: Duration,
|
||||
idle: Mutex<Duration>,
|
||||
inner: Mutex<Option<Unlocked>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(idle: Duration) -> Self {
|
||||
Self {
|
||||
idle,
|
||||
idle: Mutex::new(idle),
|
||||
inner: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn idle_interval(&self) -> Duration {
|
||||
self.idle
|
||||
*self.idle.lock().unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
/// Change the idle interval; applies from the next check.
|
||||
pub fn set_idle_interval(&self, idle: Duration) {
|
||||
*self.idle.lock().unwrap_or_else(|p| p.into_inner()) = idle;
|
||||
}
|
||||
|
||||
/// Open a wallet. Anything previously open is dropped first. The
|
||||
@@ -99,7 +104,7 @@ impl Session {
|
||||
.as_ref()
|
||||
.map(|u| now.saturating_duration_since(u.last_used));
|
||||
match idle_for {
|
||||
Some(d) if d >= self.idle => {
|
||||
Some(d) if d >= self.idle_interval() => {
|
||||
*guard = None;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -131,7 +136,7 @@ impl Session {
|
||||
})
|
||||
.collect(),
|
||||
locks_in: Some(
|
||||
self.idle
|
||||
self.idle_interval()
|
||||
.saturating_sub(now.saturating_duration_since(u.last_used))
|
||||
.as_secs(),
|
||||
),
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
pub mod keys;
|
||||
pub mod keystore;
|
||||
pub mod mnemonic;
|
||||
pub mod settings;
|
||||
pub mod substrate;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Settings on disk: a small JSON file in the app-data directory, written
|
||||
//! atomically. Nothing secret lives here; a stranger reading it learns which
|
||||
//! network the person uses and how long the wallet stays unlocked.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use wallet_entities::Settings;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SettingsError {
|
||||
#[error("settings file: {0}")]
|
||||
Io(String),
|
||||
#[error("settings file is not valid: {0}")]
|
||||
Format(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SettingsStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
pub fn at(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Defaults when the file is missing; an unreadable or malformed file
|
||||
/// is an error rather than silently reset, because losing a setting is
|
||||
/// a thing a person should hear about.
|
||||
pub fn load(&self) -> Result<Settings, SettingsError> {
|
||||
match fs::read(&self.path) {
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| SettingsError::Format(e.to_string()))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
|
||||
Err(e) => Err(SettingsError::Io(format!("{}: {e}", self.path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, settings: &Settings) -> Result<(), SettingsError> {
|
||||
if let Some(dir) = self.path.parent() {
|
||||
fs::create_dir_all(dir)
|
||||
.map_err(|e| SettingsError::Io(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
let text = serde_json::to_vec_pretty(settings)
|
||||
.map_err(|e| SettingsError::Format(e.to_string()))?;
|
||||
let dir = self.path.parent().unwrap_or(Path::new("."));
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.prefix(".settings-")
|
||||
.suffix(".tmp")
|
||||
.tempfile_in(dir)
|
||||
.map_err(|e| SettingsError::Io(e.to_string()))?;
|
||||
tmp.write_all(&text)
|
||||
.map_err(|e| SettingsError::Io(e.to_string()))?;
|
||||
tmp.as_file()
|
||||
.sync_all()
|
||||
.map_err(|e| SettingsError::Io(e.to_string()))?;
|
||||
tmp.persist(&self.path)
|
||||
.map_err(|e| SettingsError::Io(format!("{}: {}", self.path.display(), e.error)))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wallet_entities::ChainId;
|
||||
|
||||
#[test]
|
||||
fn defaults_then_round_trips_then_refuses_garbage() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = SettingsStore::at(dir.path().join("settings.json"));
|
||||
let s = store.load().unwrap();
|
||||
assert_eq!(s, Settings::default());
|
||||
let changed = Settings {
|
||||
network: ChainId("planck".into()),
|
||||
idle_lock_seconds: 120,
|
||||
show_testnets: true,
|
||||
};
|
||||
store.save(&changed).unwrap();
|
||||
assert_eq!(store.load().unwrap(), changed);
|
||||
fs::write(store.path(), b"{ not json").unwrap();
|
||||
assert!(matches!(store.load(), Err(SettingsError::Format(_))));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@
|
||||
//! chain is a [`ChainId`] and a profile somewhere in `wallet-core`; an asset is
|
||||
//! a [`AssetId`]; a quantity is an [`Amount`] of integer base units. Floats do
|
||||
//! not appear, and never will: a wallet that rounds is a wallet that lies.
|
||||
//!
|
||||
//! Integers wider than 32 bits are typed `number` on the TypeScript side:
|
||||
//! they cross the IPC as JSON numbers, which `bigint` cannot be serialised
|
||||
//! to, and none of them (block heights, seconds, nonces) approaches 2^53.
|
||||
//! Amounts are the exception and travel as strings.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
@@ -68,9 +73,12 @@ pub struct ChainStatus {
|
||||
pub endpoint: Option<String>,
|
||||
pub spec_version: Option<u32>,
|
||||
pub transaction_version: Option<u32>,
|
||||
#[ts(type = "number")]
|
||||
pub best_number: Option<u64>,
|
||||
#[ts(type = "number")]
|
||||
pub finalized_number: Option<u64>,
|
||||
/// Seconds since the last best head arrived; `None` when never.
|
||||
#[ts(type = "number")]
|
||||
pub head_age_seconds: Option<u64>,
|
||||
/// The last error, when disconnected.
|
||||
pub last_error: Option<String>,
|
||||
@@ -83,7 +91,9 @@ pub struct ChainStatus {
|
||||
#[ts(export)]
|
||||
pub struct HighSecurityInfo {
|
||||
pub guardian: String,
|
||||
#[ts(type = "number")]
|
||||
pub delay_blocks: Option<u64>,
|
||||
#[ts(type = "number")]
|
||||
pub delay_ms: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -101,12 +111,15 @@ pub struct AccountBalance {
|
||||
pub spendable: Amount,
|
||||
/// Free plus reserved.
|
||||
pub total: Amount,
|
||||
#[ts(type = "number")]
|
||||
pub nonce: u64,
|
||||
pub high_security: Option<HighSecurityInfo>,
|
||||
/// The best block the numbers were read at.
|
||||
#[ts(type = "number")]
|
||||
pub at_block: u64,
|
||||
/// The chain's finalized height at the time, so the UI can say how
|
||||
/// many confirmations deep `at_block` is.
|
||||
#[ts(type = "number")]
|
||||
pub finalized_block: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -139,7 +152,9 @@ pub struct PendingReversible {
|
||||
pub amount: Amount,
|
||||
/// When it executes, as the scheduler records it: a block number, or a
|
||||
/// timestamp in milliseconds, whichever the delay was given in.
|
||||
#[ts(type = "number")]
|
||||
pub execute_at_block: Option<u64>,
|
||||
#[ts(type = "number")]
|
||||
pub execute_at_ms: Option<u64>,
|
||||
/// Whether the open wallet's account may cancel it (sender for a
|
||||
/// one-time delay, guardian for a high-security account).
|
||||
@@ -156,8 +171,10 @@ pub struct PreparedTransferInfo {
|
||||
pub from: String,
|
||||
pub summary: CallSummary,
|
||||
pub fee: Amount,
|
||||
#[ts(type = "number")]
|
||||
pub nonce: u64,
|
||||
/// Blocks the transaction stays valid for from the block it was built at.
|
||||
#[ts(type = "number")]
|
||||
pub valid_for_blocks: u64,
|
||||
}
|
||||
|
||||
@@ -358,6 +375,7 @@ pub struct WalletSummary {
|
||||
pub address: String,
|
||||
pub kind: WalletKind,
|
||||
/// Unix seconds; the file's `created_at`.
|
||||
#[ts(type = "number")]
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
@@ -395,9 +413,34 @@ pub struct SessionStatusInfo {
|
||||
pub locked: bool,
|
||||
pub wallet: Option<WalletSummary>,
|
||||
pub accounts: Vec<SessionAccountInfo>,
|
||||
#[ts(type = "number")]
|
||||
pub locks_in_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
/// Persisted preferences. Nothing here is secret.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(default)]
|
||||
#[ts(export)]
|
||||
pub struct Settings {
|
||||
/// The chain the UI is showing.
|
||||
pub network: ChainId,
|
||||
/// Idle time before the session locks itself.
|
||||
#[ts(type = "number")]
|
||||
pub idle_lock_seconds: u64,
|
||||
/// Whether testnet profiles are offered in the switcher.
|
||||
pub show_testnets: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
network: ChainId("quantus".into()),
|
||||
idle_lock_seconds: 10 * 60,
|
||||
show_testnets: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app tells the UI about itself at startup.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
|
||||
+28
-9
@@ -1,16 +1,35 @@
|
||||
import { useAppInfo } from './api/hooks'
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom'
|
||||
import { useSession, useSessionLockedListener } from './api/hooks'
|
||||
import { LockScreen } from './components/LockScreen'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { Accounts } from './routes/Accounts'
|
||||
import { Placeholder } from './routes/Placeholder'
|
||||
import { SettingsRoute } from './routes/Settings'
|
||||
|
||||
// The webview renders; it never holds a key, decodes a transaction, or does
|
||||
// arithmetic on an amount. Everything it shows came from a Rust command.
|
||||
export default function App() {
|
||||
const info = useAppInfo()
|
||||
|
||||
useSessionLockedListener()
|
||||
const session = useSession()
|
||||
if (session.isPending) return <div className="lock muted">starting…</div>
|
||||
if (session.isError) return <div className="lock error">{session.error.message}</div>
|
||||
if (session.data.locked) return <LockScreen />
|
||||
return (
|
||||
<main>
|
||||
<h1>blackbeard wallet</h1>
|
||||
{info.isPending && <p>starting…</p>}
|
||||
{info.isError && <p role="alert">{info.error.message}</p>}
|
||||
{info.isSuccess && <p>v{info.data.version}</p>}
|
||||
</main>
|
||||
<HashRouter>
|
||||
<div className="shell">
|
||||
<Sidebar />
|
||||
<main className="main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Accounts />} />
|
||||
<Route path="/send" element={<Placeholder title="Send" issue={28} />} />
|
||||
<Route path="/receive" element={<Placeholder title="Receive" issue={27} />} />
|
||||
<Route path="/history" element={<Placeholder title="History" issue={30} />} />
|
||||
<Route path="/settings" element={<SettingsRoute />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<StatusBar />
|
||||
</div>
|
||||
</HashRouter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Events Rust emits. One subscription helper per event so a screen never
|
||||
// spells an event name itself.
|
||||
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type { AccountBalance } from './generated/AccountBalance'
|
||||
import type { TxStatusInfo } from './generated/TxStatusInfo'
|
||||
|
||||
export function onSessionLocked(handler: () => void): Promise<UnlistenFn> {
|
||||
return listen('session:locked', () => handler())
|
||||
}
|
||||
|
||||
export function onBalancesUpdated(
|
||||
handler: (balances: AccountBalance[]) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<AccountBalance[]>('balances:updated', (e) => handler(e.payload))
|
||||
}
|
||||
|
||||
export function onTxStatus(handler: (status: TxStatusInfo) => void): Promise<UnlistenFn> {
|
||||
return listen<TxStatusInfo>('tx:status', (e) => handler(e.payload))
|
||||
}
|
||||
@@ -14,13 +14,13 @@ spendable: Amount,
|
||||
/**
|
||||
* Free plus reserved.
|
||||
*/
|
||||
total: Amount, nonce: bigint, high_security: HighSecurityInfo | null,
|
||||
total: Amount, nonce: number, high_security: HighSecurityInfo | null,
|
||||
/**
|
||||
* The best block the numbers were read at.
|
||||
*/
|
||||
at_block: bigint,
|
||||
at_block: number,
|
||||
/**
|
||||
* The chain's finalized height at the time, so the UI can say how
|
||||
* many confirmations deep `at_block` is.
|
||||
*/
|
||||
finalized_block: bigint | null, };
|
||||
finalized_block: number, };
|
||||
|
||||
@@ -9,11 +9,11 @@ export type ChainStatus = { chain: ChainId, connected: boolean,
|
||||
/**
|
||||
* The endpoint currently in use, or the last one tried.
|
||||
*/
|
||||
endpoint: string | null, spec_version: number | null, transaction_version: number | null, best_number: bigint | null, finalized_number: bigint | null,
|
||||
endpoint: string | null, spec_version: number | null, transaction_version: number | null, best_number: number, finalized_number: number,
|
||||
/**
|
||||
* Seconds since the last best head arrived; `None` when never.
|
||||
*/
|
||||
head_age_seconds: bigint | null,
|
||||
head_age_seconds: number,
|
||||
/**
|
||||
* The last error, when disconnected.
|
||||
*/
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
/**
|
||||
* A high-security account's guardian and cancellation delay.
|
||||
*/
|
||||
export type HighSecurityInfo = { guardian: string, delay_blocks: bigint | null, delay_ms: bigint | null, };
|
||||
export type HighSecurityInfo = { guardian: string, delay_blocks: number, delay_ms: number, };
|
||||
|
||||
@@ -9,7 +9,7 @@ export type PendingReversible = { tx_id: string, from: string, to: string, guard
|
||||
* When it executes, as the scheduler records it: a block number, or a
|
||||
* timestamp in milliseconds, whichever the delay was given in.
|
||||
*/
|
||||
execute_at_block: bigint | null, execute_at_ms: bigint | null,
|
||||
execute_at_block: number, execute_at_ms: number,
|
||||
/**
|
||||
* Whether the open wallet's account may cancel it (sender for a
|
||||
* one-time delay, guardian for a high-security account).
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { ChainId } from "./ChainId";
|
||||
* A transaction prepared for review: what will be signed, decoded from
|
||||
* the bytes, and what it costs. `id` is handed back to submit it.
|
||||
*/
|
||||
export type PreparedTransferInfo = { id: string, chain: ChainId, from: string, summary: CallSummary, fee: Amount, nonce: bigint,
|
||||
export type PreparedTransferInfo = { id: string, chain: ChainId, from: string, summary: CallSummary, fee: Amount, nonce: number,
|
||||
/**
|
||||
* Blocks the transaction stays valid for from the block it was built at.
|
||||
*/
|
||||
valid_for_blocks: bigint, };
|
||||
valid_for_blocks: number, };
|
||||
|
||||
@@ -6,4 +6,4 @@ import type { WalletSummary } from "./WalletSummary";
|
||||
* Whether a wallet is open, which one, and how long until the idle rule
|
||||
* locks it again.
|
||||
*/
|
||||
export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array<SessionAccountInfo>, locks_in_seconds: bigint | null, };
|
||||
export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array<SessionAccountInfo>, locks_in_seconds: number, };
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ChainId } from "./ChainId";
|
||||
|
||||
/**
|
||||
* Persisted preferences. Nothing here is secret.
|
||||
*/
|
||||
export type Settings = {
|
||||
/**
|
||||
* The chain the UI is showing.
|
||||
*/
|
||||
network: ChainId,
|
||||
/**
|
||||
* Idle time before the session locks itself.
|
||||
*/
|
||||
idle_lock_seconds: number,
|
||||
/**
|
||||
* Whether testnet profiles are offered in the switcher.
|
||||
*/
|
||||
show_testnets: boolean, };
|
||||
@@ -8,4 +8,4 @@ export type WalletSummary = { name: string, address: string, kind: WalletKind,
|
||||
/**
|
||||
* Unix seconds; the file's `created_at`.
|
||||
*/
|
||||
created_at: bigint, };
|
||||
created_at: number, };
|
||||
|
||||
+110
-2
@@ -1,13 +1,121 @@
|
||||
// React Query wrappers around the command client. Server state (what Rust
|
||||
// knows) lives in the query cache; component state stays local.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { appInfo } from './wallet'
|
||||
import { useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { ChainId } from './generated/ChainId'
|
||||
import type { Settings } from './generated/Settings'
|
||||
import { onBalancesUpdated, onSessionLocked } from './events'
|
||||
import {
|
||||
appInfo,
|
||||
balances,
|
||||
balancesWatch,
|
||||
chainConnect,
|
||||
chainStatus,
|
||||
chainsList,
|
||||
lock,
|
||||
sessionStatus,
|
||||
settingsGet,
|
||||
settingsSet,
|
||||
unlock,
|
||||
walletsList,
|
||||
} from './wallet'
|
||||
|
||||
export const queryKeys = {
|
||||
appInfo: ['app-info'] as const,
|
||||
settings: ['settings'] as const,
|
||||
chains: ['chains'] as const,
|
||||
session: ['session'] as const,
|
||||
wallets: ['wallets'] as const,
|
||||
chainStatus: (chain: ChainId) => ['chain-status', chain] as const,
|
||||
balances: (chain: ChainId) => ['balances', chain] as const,
|
||||
}
|
||||
|
||||
export function useAppInfo() {
|
||||
return useQuery({ queryKey: queryKeys.appInfo, queryFn: appInfo, staleTime: Infinity })
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
return useQuery({ queryKey: queryKeys.settings, queryFn: settingsGet, staleTime: Infinity })
|
||||
}
|
||||
|
||||
export function useSetSettings() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (s: Settings) => settingsSet(s),
|
||||
onSuccess: (s) => qc.setQueryData(queryKeys.settings, s),
|
||||
})
|
||||
}
|
||||
|
||||
export function useChains() {
|
||||
return useQuery({ queryKey: queryKeys.chains, queryFn: chainsList, staleTime: Infinity })
|
||||
}
|
||||
|
||||
export function useWallets() {
|
||||
return useQuery({ queryKey: queryKeys.wallets, queryFn: walletsList })
|
||||
}
|
||||
|
||||
export function useSession() {
|
||||
return useQuery({ queryKey: queryKeys.session, queryFn: sessionStatus, refetchInterval: 15_000 })
|
||||
}
|
||||
|
||||
export function useUnlock() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ name, password }: { name: string; password: string }) => unlock(name, password),
|
||||
onSuccess: (s) => qc.setQueryData(queryKeys.session, s),
|
||||
})
|
||||
}
|
||||
|
||||
export function useLock() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: lock,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.session }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Connect the chain (idempotent on the Rust side) and poll its status.
|
||||
export function useChainStatus(chain: ChainId | undefined) {
|
||||
return useQuery({
|
||||
queryKey: chain ? queryKeys.chainStatus(chain) : ['chain-status', 'none'],
|
||||
queryFn: () => chainConnect(chain!).then(() => chainStatus(chain!)),
|
||||
enabled: !!chain,
|
||||
refetchInterval: 5_000,
|
||||
})
|
||||
}
|
||||
|
||||
/// Balances of the open wallet on `chain`, refreshed by Rust on every head.
|
||||
export function useBalances(chain: ChainId | undefined, unlocked: boolean) {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
if (!chain || !unlocked) return
|
||||
let stop: (() => void) | undefined
|
||||
balancesWatch(chain).catch(() => undefined)
|
||||
onBalancesUpdated((b) => {
|
||||
if (b.length > 0 && b[0].chain === chain) qc.setQueryData(queryKeys.balances(chain), b)
|
||||
}).then((un) => {
|
||||
stop = un
|
||||
})
|
||||
return () => stop?.()
|
||||
}, [chain, unlocked, qc])
|
||||
return useQuery({
|
||||
queryKey: chain ? queryKeys.balances(chain) : ['balances', 'none'],
|
||||
queryFn: () => balances(chain!),
|
||||
enabled: !!chain && unlocked,
|
||||
retry: 2,
|
||||
retryDelay: 1_500,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop the session in the cache when Rust says the idle rule fired.
|
||||
export function useSessionLockedListener() {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
let stop: (() => void) | undefined
|
||||
onSessionLocked(() => qc.invalidateQueries({ queryKey: queryKeys.session })).then((un) => {
|
||||
stop = un
|
||||
})
|
||||
return () => stop?.()
|
||||
}, [qc])
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { PendingReversible } from './generated/PendingReversible'
|
||||
import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
|
||||
import type { CreationStart } from './generated/CreationStart'
|
||||
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
|
||||
import type { Settings } from './generated/Settings'
|
||||
import type { SignatureScheme } from './generated/SignatureScheme'
|
||||
import type { WalletSummary } from './generated/WalletSummary'
|
||||
import type { WalletError as WalletErrorShape } from './generated/WalletError'
|
||||
@@ -169,3 +170,11 @@ export function reversibleCancelPrepare(
|
||||
): Promise<PreparedTransferInfo> {
|
||||
return command<PreparedTransferInfo>('reversible_cancel_prepare', { chain, from, txId })
|
||||
}
|
||||
|
||||
export function settingsGet(): Promise<Settings> {
|
||||
return command<Settings>('settings_get')
|
||||
}
|
||||
|
||||
export function settingsSet(settings: Settings): Promise<Settings> {
|
||||
return command<Settings>('settings_set', { settings })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useChains, useSettings } from '../api/hooks'
|
||||
|
||||
/** A one-line warning above testnet content: this is not money. */
|
||||
export function ChainCaveat() {
|
||||
const settings = useSettings()
|
||||
const chains = useChains()
|
||||
const profile = chains.data?.find((c) => c.id === settings.data?.network)
|
||||
if (!profile || profile.kind === 'mainnet') return null
|
||||
return (
|
||||
<p className="warn" style={{ marginTop: 0 }}>
|
||||
{profile.display_name} is a {profile.kind}: its {profile.token_symbol} has no value.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useUnlock, useWallets } from '../api/hooks'
|
||||
|
||||
/**
|
||||
* Fronts everything while the session is locked. Picks a wallet file and
|
||||
* takes a password; the password goes to Rust and nowhere else.
|
||||
*/
|
||||
export function LockScreen() {
|
||||
const wallets = useWallets()
|
||||
const unlock = useUnlock()
|
||||
const [name, setName] = useState<string>('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const list = wallets.data ?? []
|
||||
const selected = name || list[0]?.name || ''
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!selected) return
|
||||
unlock.mutate({ name: selected, password }, { onSettled: () => setPassword('') })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lock">
|
||||
<form className="panel" onSubmit={submit}>
|
||||
<div className="panel-head">
|
||||
<h1 className="panel-title">Unlock</h1>
|
||||
<span className="eyebrow">blackbeard wallet</span>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{wallets.isPending && <p className="muted">Reading wallets…</p>}
|
||||
{wallets.isError && <p className="error">{wallets.error.message}</p>}
|
||||
{wallets.isSuccess && list.length === 0 && (
|
||||
<p className="muted">
|
||||
No wallets yet. Creating and importing arrive with the onboarding flow; until then,
|
||||
place a quantus-cli wallet file in the keystore directory.
|
||||
</p>
|
||||
)}
|
||||
{list.length > 0 && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="wallet">Wallet</label>
|
||||
<select
|
||||
id="wallet"
|
||||
className="select"
|
||||
value={selected}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
>
|
||||
{list.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name} · {w.address.slice(0, 8)}…
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{unlock.isError && <p className="error">{unlock.error.message}</p>}
|
||||
<div className="row">
|
||||
<button className="button primary" type="submit" disabled={unlock.isPending}>
|
||||
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useChains, useSetSettings, useSettings } from '../api/hooks'
|
||||
|
||||
/**
|
||||
* Mainnet and testnets are separated and labelled, as on the observer: a
|
||||
* balance on a testnet is not money, and the chrome should say so before
|
||||
* the number does. Testnets only appear when settings allow them.
|
||||
*/
|
||||
export function NetworkSwitcher() {
|
||||
const chains = useChains()
|
||||
const settings = useSettings()
|
||||
const set = useSetSettings()
|
||||
if (!chains.data || !settings.data) return null
|
||||
const showTestnets = settings.data.show_testnets
|
||||
const visible = chains.data.filter((c) => c.kind === 'mainnet' || showTestnets)
|
||||
const current = settings.data.network
|
||||
return (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label htmlFor="network" className="eyebrow">
|
||||
Network
|
||||
</label>
|
||||
<select
|
||||
id="network"
|
||||
className="select"
|
||||
value={current}
|
||||
onChange={(e) => set.mutate({ ...settings.data, network: e.target.value })}
|
||||
>
|
||||
{visible.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.display_name}
|
||||
{c.kind !== 'mainnet' ? ` (${c.kind})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { useLock, useSession } from '../api/hooks'
|
||||
import { NetworkSwitcher } from './NetworkSwitcher'
|
||||
|
||||
const SECTIONS = [
|
||||
{ to: '/', label: 'Accounts' },
|
||||
{ to: '/send', label: 'Send' },
|
||||
{ to: '/receive', label: 'Receive' },
|
||||
{ to: '/history', label: 'History' },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const session = useSession()
|
||||
const lock = useLock()
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div>
|
||||
<div className="mark-name">
|
||||
black<span>beard</span>
|
||||
</div>
|
||||
<div className="mark-tagline">wallet</div>
|
||||
</div>
|
||||
<nav className="sections" aria-label="Section">
|
||||
{SECTIONS.map((s) => (
|
||||
<NavLink
|
||||
key={s.to}
|
||||
to={s.to}
|
||||
end={s.to === '/'}
|
||||
className="section-link"
|
||||
aria-current={undefined}
|
||||
>
|
||||
{s.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<NetworkSwitcher />
|
||||
{session.data?.wallet && (
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{session.data.wallet.name}
|
||||
{session.data.locks_in_seconds != null && (
|
||||
<> · locks in {Math.ceil(session.data.locks_in_seconds / 60)} min</>
|
||||
)}
|
||||
</span>
|
||||
<button className="button" onClick={() => lock.mutate()}>
|
||||
Lock
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useChainStatus, useChains, useSettings } from '../api/hooks'
|
||||
|
||||
export function StatusBar() {
|
||||
const settings = useSettings()
|
||||
const chains = useChains()
|
||||
const status = useChainStatus(settings.data?.network)
|
||||
const profile = chains.data?.find((c) => c.id === settings.data?.network)
|
||||
const s = status.data
|
||||
return (
|
||||
<footer className="statusbar">
|
||||
<span>
|
||||
<span className={`dot ${s?.connected ? 'on' : s ? 'off' : ''}`} />
|
||||
{profile?.display_name ?? '…'}
|
||||
{profile && profile.kind !== 'mainnet' && (
|
||||
<>
|
||||
{' '}
|
||||
<span className="chip testnet">{profile.kind}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{s?.connected ? (
|
||||
<>
|
||||
<span className="mono">{s.endpoint}</span>
|
||||
<span className="numeral">
|
||||
best #{s.best_number ?? '…'} · final #{s.finalized_number ?? '…'}
|
||||
</span>
|
||||
<span className="numeral">spec {s.spec_version}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="muted">{s?.last_error ?? 'connecting…'}</span>
|
||||
)}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
+297
-6
@@ -1,12 +1,303 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
@import './tokens.css';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: radial-gradient(120% 60% at 50% -10%, var(--glow), transparent 60%), var(--surface-0);
|
||||
background-attachment: fixed;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* A desktop app, not a page: no text selection on chrome, no rubber-band. */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--data-bright);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem;
|
||||
/* ---- type ---------------------------------------------------------------- */
|
||||
.display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.numeral {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.good {
|
||||
color: var(--good);
|
||||
}
|
||||
.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.bad {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- shell --------------------------------------------------------------- */
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
grid-template-rows: 1fr auto;
|
||||
height: 100%;
|
||||
}
|
||||
.sidebar {
|
||||
grid-row: 1 / span 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 22px 16px;
|
||||
background: var(--surface-1);
|
||||
border-right: var(--rule);
|
||||
user-select: none;
|
||||
}
|
||||
.mark-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
.mark-name span {
|
||||
color: var(--data);
|
||||
}
|
||||
.mark-tagline {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.section-link {
|
||||
padding: 8px 10px;
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.section-link:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.section-link[aria-current='page'] {
|
||||
background: var(--data-wash);
|
||||
color: var(--text-primary);
|
||||
box-shadow: inset 2px 0 0 var(--data);
|
||||
}
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.main {
|
||||
padding: 24px 28px 40px;
|
||||
overflow: auto;
|
||||
}
|
||||
.statusbar {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
border-top: var(--rule);
|
||||
background: var(--surface-1);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dot.on {
|
||||
background: var(--good);
|
||||
box-shadow: 0 0 0 3px var(--good-halo);
|
||||
}
|
||||
.dot.off {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- panels and forms ---------------------------------------------------- */
|
||||
.panel {
|
||||
background: var(--surface-1);
|
||||
border: var(--rule);
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
border-bottom: var(--rule);
|
||||
}
|
||||
.panel-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
.panel-body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.input,
|
||||
.select {
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.input.mono {
|
||||
font-size: 13px;
|
||||
}
|
||||
.button {
|
||||
padding: 8px 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.button:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.button.primary {
|
||||
background: var(--data);
|
||||
border-color: var(--data);
|
||||
color: #14110d;
|
||||
font-weight: 600;
|
||||
}
|
||||
.button.primary:hover {
|
||||
background: var(--data-bright);
|
||||
}
|
||||
.button.danger {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.error {
|
||||
color: var(--accent);
|
||||
background: var(--accent-wash);
|
||||
border: 1px solid var(--accent);
|
||||
padding: 8px 12px;
|
||||
border-radius: 3px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.chip {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--data-chip);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.chip.testnet {
|
||||
border: 1px dashed var(--warn-border);
|
||||
background: transparent;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* ---- lock screen --------------------------------------------------------- */
|
||||
.lock {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.lock .panel {
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
|
||||
/* ---- tables -------------------------------------------------------------- */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.table th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
border-bottom: var(--rule);
|
||||
}
|
||||
.table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: var(--rule);
|
||||
vertical-align: top;
|
||||
}
|
||||
.table td.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Rust renders amounts exactly; this only inserts thousands separators in
|
||||
// the whole part for reading. No arithmetic happens here.
|
||||
export function formatAmount(a: { raw: string; decimals: number }): string {
|
||||
const raw = a.raw.padStart(a.decimals + 1, '0')
|
||||
const whole = raw.slice(0, raw.length - a.decimals)
|
||||
const frac = raw.slice(raw.length - a.decimals).replace(/0+$/, '')
|
||||
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return frac ? `${grouped}.${frac}` : grouped
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Theme preference, applied before first paint from main.tsx. "auto" follows
|
||||
// the system; the attribute is always a concrete value so CSS stays to one
|
||||
// definition per palette.
|
||||
|
||||
export type ThemePreference = 'auto' | 'dark' | 'light'
|
||||
const KEY = 'blackbeard.theme'
|
||||
|
||||
export function storedTheme(): ThemePreference {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY)
|
||||
return v === 'dark' || v === 'light' ? v : 'auto'
|
||||
} catch {
|
||||
return 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTheme(pref: ThemePreference) {
|
||||
const system = window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
||||
document.documentElement.setAttribute('data-theme', pref === 'auto' ? system : pref)
|
||||
}
|
||||
|
||||
export function setTheme(pref: ThemePreference) {
|
||||
try {
|
||||
localStorage.setItem(KEY, pref)
|
||||
} catch {
|
||||
// A private window; the preference lasts the session.
|
||||
}
|
||||
applyTheme(pref)
|
||||
}
|
||||
@@ -2,8 +2,13 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App.tsx'
|
||||
import { applyTheme, storedTheme } from './lib/theme'
|
||||
import './index.css'
|
||||
|
||||
// Resolve the theme before the first paint; there is no inline script
|
||||
// because the CSP allows none.
|
||||
applyTheme(storedTheme())
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useBalances, useSession, useSettings } from '../api/hooks'
|
||||
import { ChainCaveat } from '../components/ChainCaveat'
|
||||
import { formatAmount } from '../lib/format'
|
||||
|
||||
/**
|
||||
* The open wallet's accounts on the selected network, with balances read
|
||||
* at the best block and refreshed by Rust on every head. Confirmations are
|
||||
* the distance to the finalized height.
|
||||
*/
|
||||
export function Accounts() {
|
||||
const settings = useSettings()
|
||||
const session = useSession()
|
||||
const unlocked = !!session.data && !session.data.locked
|
||||
const balances = useBalances(settings.data?.network, unlocked)
|
||||
return (
|
||||
<>
|
||||
<ChainCaveat />
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Accounts</h2>
|
||||
{balances.data?.[0] && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
at block <span className="numeral">{balances.data[0].at_block}</span>
|
||||
{balances.data[0].finalized_block != null && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<span className="numeral">
|
||||
{Math.max(0, balances.data[0].at_block - balances.data[0].finalized_block)}
|
||||
</span>{' '}
|
||||
from final
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{balances.isPending && <p className="panel-body muted">Reading balances from the chain…</p>}
|
||||
{balances.isError && <p className="error">{balances.error.message}</p>}
|
||||
{balances.isSuccess && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Address</th>
|
||||
<th>Scheme</th>
|
||||
<th className="num">Spendable</th>
|
||||
<th className="num">Total</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{balances.data.map((b, i) => (
|
||||
<tr key={b.address}>
|
||||
<td className="mono">{b.address}</td>
|
||||
<td>{session.data?.accounts[i]?.scheme ?? ''}</td>
|
||||
<td className="num numeral">
|
||||
{b.spendable.raw === '0' ? '0' : formatAmount(b.spendable)} {b.symbol}
|
||||
</td>
|
||||
<td className="num numeral">
|
||||
{formatAmount(b.total)} {b.symbol}
|
||||
</td>
|
||||
<td className="muted">
|
||||
{b.high_security ? 'high-security account: transfers are delayed' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function Placeholder({ title, issue }: { title: string; issue: number }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">{title}</h2>
|
||||
</div>
|
||||
<p className="panel-body muted">Arrives with issue #{issue}.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import { useAppInfo, useSetSettings, useSettings } from '../api/hooks'
|
||||
import { setTheme, storedTheme, type ThemePreference } from '../lib/theme'
|
||||
|
||||
export function SettingsRoute() {
|
||||
const settings = useSettings()
|
||||
const set = useSetSettings()
|
||||
const info = useAppInfo()
|
||||
const [theme, setThemeState] = useState<ThemePreference>(storedTheme())
|
||||
if (!settings.data) return null
|
||||
const s = settings.data
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Settings</h2>
|
||||
{info.data && <span className="muted">v{info.data.version}</span>}
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<div className="field">
|
||||
<label htmlFor="idle">Lock after idle (minutes)</label>
|
||||
<input
|
||||
id="idle"
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={Math.round(s.idle_lock_seconds / 60)}
|
||||
onChange={(e) =>
|
||||
set.mutate({
|
||||
...s,
|
||||
idle_lock_seconds: Math.max(1, Number(e.target.value)) * 60,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.show_testnets}
|
||||
onChange={(e) => set.mutate({ ...s, show_testnets: e.target.checked })}
|
||||
/>{' '}
|
||||
Offer testnets in the network switcher
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="theme">Theme</label>
|
||||
<select
|
||||
id="theme"
|
||||
className="select"
|
||||
value={theme}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as ThemePreference
|
||||
setThemeState(v)
|
||||
setTheme(v)
|
||||
}}
|
||||
>
|
||||
<option value="auto">Follow the system</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
{set.isError && <p className="error">{set.error.message}</p>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Design tokens, lifted from blackbeard.observer's design system so the wallet
|
||||
* and the scoreboard read as one family. Two palettes, both selected rather
|
||||
* than computed from each other; see the observer's index.css for the
|
||||
* reasoning and the validator commands behind every value. The wallet adds
|
||||
* nothing to the palette: identity is bronze, alarm is crimson, and a balance
|
||||
* is a numeral in tabular figures.
|
||||
*/
|
||||
:root,
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--surface-0: #0d0b09;
|
||||
--surface-1: #14110d;
|
||||
--surface-2: #1c1813;
|
||||
--surface-3: #262019;
|
||||
--border: #2f2719;
|
||||
--border-strong: #453923;
|
||||
--text-primary: #f2ece0;
|
||||
--text-secondary: #a2937c;
|
||||
--text-muted: #7a6c59;
|
||||
--data: #bd8829;
|
||||
--data-bright: #e0a63a;
|
||||
--data-wash: rgba(189, 136, 41, 0.1);
|
||||
--accent: #d8453a;
|
||||
--accent-bright: #f05a4c;
|
||||
--accent-wash: rgba(216, 69, 58, 0.1);
|
||||
--good: #3f9d76;
|
||||
--warn: #d9a441;
|
||||
--font-display: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;
|
||||
--font-body: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||
--glow: rgba(189, 136, 41, 0.08);
|
||||
--good-halo: rgba(63, 157, 118, 0.18);
|
||||
--data-chip: rgba(189, 136, 41, 0.18);
|
||||
--warn-border: rgba(217, 164, 65, 0.4);
|
||||
--failed-chip: rgba(216, 69, 58, 0.12);
|
||||
--rule: 1px solid var(--border);
|
||||
--shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
--surface-0: #fbf8f2;
|
||||
--surface-1: #f5f1e8;
|
||||
--surface-2: #ece7da;
|
||||
--surface-3: #e2dbc9;
|
||||
--border: #d9d0ba;
|
||||
--border-strong: #b8ab8d;
|
||||
--text-primary: #1b1710;
|
||||
--text-secondary: #5b5142;
|
||||
--text-muted: #7c7263;
|
||||
--data: #a8741c;
|
||||
--data-bright: #8a5f10;
|
||||
--data-wash: rgba(168, 116, 28, 0.12);
|
||||
--accent: #c3382d;
|
||||
--accent-bright: #a82c20;
|
||||
--accent-wash: rgba(195, 56, 45, 0.1);
|
||||
--good: #0f8a58;
|
||||
--warn: #b8651a;
|
||||
--glow: rgba(168, 116, 28, 0.09);
|
||||
--good-halo: rgba(15, 138, 88, 0.16);
|
||||
--data-chip: rgba(168, 116, 28, 0.16);
|
||||
--warn-border: rgba(184, 101, 26, 0.45);
|
||||
--failed-chip: rgba(195, 56, 45, 0.09);
|
||||
--shadow: 0 1px 0 rgba(27, 23, 16, 0.04) inset;
|
||||
}
|
||||
Reference in New Issue
Block a user