feat(desktop): Contacts + Settings→Devices + expanded Profile + QR + keybinds (v2.2.0-rc1)
Completes the desktop feature surface ahead of the v2.2.0 tag. Only
auto-update + packaging remain.
Settings — now two-paned (nav on the left, pages on the right):
* NodePage — URL ping-on-commit + API token field.
* IdentityPage — pub key / X25519 pub, Export (safe-save dialog) /
Import (open dialog + wipe + replace) / Delete identity.
* DevicesPage — full multi-device UI: list every active device with
a THIS DEVICE badge; Unlink button on every other row submits
UNLINK_DEVICE + optimistic local remove; Link new device modal
takes {code, device key, name}, submits LINK_DEVICE, then ships
the handshake envelope (master Ed25519 priv encrypted for the
new X25519) — same protocol as mobile's primary-device modal.
* AboutPage — version, platform, Gitea links.
* store.settingsPage discriminated union keeps selection across
section switches.
Contacts section (now real):
* ContactsList — alphabetical, filter-as-you-type; each row shows
avatar letter + name + short address.
* ContactsDetail — profile card (username/alias/pub) + Open chat /
View posts / Copy address actions + stats grid
(Balance, Devices, Encryption, Added) + Identity card with
DC address, username, published X25519, device_count.
* store.selectedContact persists across navigation.
Profile section (expanded):
* ProfileList — big avatar + pub key + contacts count.
* ProfileDetail — balance hero, quick actions (My posts →
feed author wall, Manage devices → Settings→Devices, Copy
address), Identity card, inline Linked devices list with a
THIS DEVICE badge matching the Settings page.
Receive modal — canvas QR via `qrcode` (new dep, ~5 KB gzipped),
white-on-transparent so it sits inside the same black modal chrome.
Global keybinds (useGlobalKeybinds hook mounted in Shell):
* Ctrl/Cmd+W — close the current conversation (drops activeChat,
keeps section). Does NOT close the window.
* Ctrl/Cmd+K — jump to Contacts.
* Ctrl/Cmd+, — Settings.
Each guards against being in a text field so typing `k,` in a
composer / search doesn't hijack.
docs/ROADMAP.md — rc1 row flipped to done; v2.2.0 narrows to
auto-update + packaging + optional attachments in Compose.
This commit is contained in:
59
desktop/src/sections/settings/AboutPage.tsx
Normal file
59
desktop/src/sections/settings/AboutPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
// AboutPage — version info, platform, build links. Reads app.version
|
||||
// via the preload IPC bridge.
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PageLayout } from './PageLayout';
|
||||
import { Card, Label, Hint } from './NodePage';
|
||||
|
||||
export function AboutPage(): React.ReactElement {
|
||||
const [version, setVersion] = useState('dev');
|
||||
const [platform, setPlatform] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
window.dchain?.app.version().then(setVersion).catch(() => {});
|
||||
window.dchain?.app.platform().then(setPlatform).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout title="About">
|
||||
<Card>
|
||||
<Label>Build</Label>
|
||||
<div style={{ color: '#fff', fontSize: 14, fontFamily: 'monospace' }}>
|
||||
DChain Desktop v{version}
|
||||
</div>
|
||||
<Hint>
|
||||
Running on {platform || 'unknown'} · Electron / Chromium
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>Links</Label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<LinkRow
|
||||
href="https://git.vsecoder.vodka/vsecoder/dchain"
|
||||
label="Source code (Gitea)"
|
||||
/>
|
||||
<LinkRow
|
||||
href="https://git.vsecoder.vodka/vsecoder/dchain/releases"
|
||||
label="Releases"
|
||||
/>
|
||||
<LinkRow
|
||||
href="https://git.vsecoder.vodka/vsecoder/dchain/src/branch/main/docs"
|
||||
label="Documentation"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkRow({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#1d9bf0', fontSize: 13, textDecoration: 'none' }}
|
||||
>{label} ↗</a>
|
||||
);
|
||||
}
|
||||
353
desktop/src/sections/settings/DevicesPage.tsx
Normal file
353
desktop/src/sections/settings/DevicesPage.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
// DevicesPage — multi-device registry UI.
|
||||
//
|
||||
// Top: list of on-chain devices for this identity. Each row has:
|
||||
// * badge for "this device" (cannot be unlinked from here — you'd
|
||||
// wipe yourself on next boot)
|
||||
// * device name + truncated X25519 pub + added-at
|
||||
// * Unlink button for others (submits UNLINK_DEVICE tx)
|
||||
//
|
||||
// Bottom: "Link new device" modal, same protocol as mobile's
|
||||
// Settings → Devices → Link new device.
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
fetchDevices, type DeviceInfo,
|
||||
} from '@/lib/api';
|
||||
import { buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx, humanizeTxError } from '@/lib/tx';
|
||||
import { sendEnvelope } from '@/lib/relay';
|
||||
import { encryptMessage, shortAddr } from '@/lib/crypto';
|
||||
import { PageLayout } from './PageLayout';
|
||||
import { Card, Label, Hint, inputStyle } from './NodePage';
|
||||
import { Button } from './IdentityPage';
|
||||
|
||||
export function DevicesPage(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [devs, setDevs] = useState<DeviceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [unlinking, setUnlinking] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!keyFile) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
setDevs(await fetchDevices(keyFile.pub_key));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const onUnlink = useCallback(async (d: DeviceInfo) => {
|
||||
if (!keyFile) return;
|
||||
if (!confirm(
|
||||
`Unlink "${d.device_name}"? It will stop receiving messages sent to you. ` +
|
||||
`The device itself will wipe its local state next time it checks in. ` +
|
||||
`This costs a small network fee.`,
|
||||
)) return;
|
||||
setUnlinking(d.x25519_pub_key);
|
||||
setNotice(null);
|
||||
try {
|
||||
const tx = buildUnlinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: d.x25519_pub_key,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
setDevs(prev => prev.filter(x => x.x25519_pub_key !== d.x25519_pub_key));
|
||||
setNotice(`Unlinked — registry will converge in a block or two.`);
|
||||
} catch (e) {
|
||||
setNotice(`Unlink failed: ${humanizeTxError(e)}`);
|
||||
} finally {
|
||||
setUnlinking(null);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
const meX25519 = keyFile?.x25519_pub ?? '';
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Devices"
|
||||
subtitle="Every linked device gets its own encryption key; messages sent to you are delivered to all of them."
|
||||
>
|
||||
<Card>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}>
|
||||
<Label>Linked devices</Label>
|
||||
<Button onClick={() => setLinkOpen(true)}>Link new device</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 12 }}>Loading…</div>
|
||||
) : devs.length === 0 ? (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 12 }}>
|
||||
No devices registered yet. This device auto-links once a small
|
||||
network fee is available in your balance — pull to refresh after
|
||||
a first transfer if the list stays empty.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{devs.map((d, i) => (
|
||||
<DeviceRow
|
||||
key={d.x25519_pub_key}
|
||||
d={d}
|
||||
isMe={d.x25519_pub_key === meX25519}
|
||||
unlinking={unlinking === d.x25519_pub_key}
|
||||
onUnlink={() => onUnlink(d)}
|
||||
first={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div style={{
|
||||
marginTop: 10, padding: 10, borderRadius: 8,
|
||||
background: notice.startsWith('Unlink failed')
|
||||
? '#2a1414' : '#0d2540',
|
||||
color: notice.startsWith('Unlink failed') ? '#ff9b9b' : '#1d9bf0',
|
||||
fontSize: 12,
|
||||
}}>{notice}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{linkOpen && (
|
||||
<LinkNewDeviceModal
|
||||
onClose={() => setLinkOpen(false)}
|
||||
onLinked={() => { setLinkOpen(false); setTimeout(load, 1000); }}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function DeviceRow({
|
||||
d, isMe, unlinking, onUnlink, first,
|
||||
}: {
|
||||
d: DeviceInfo; isMe: boolean; unlinking: boolean;
|
||||
onUnlink: () => void; first: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '12px 0',
|
||||
borderTop: first ? undefined : '1px solid #1f1f1f',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: isMe ? '#0d2540' : '#1a1a1a',
|
||||
color: isMe ? '#1d9bf0' : '#d0d0d0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 16,
|
||||
}}>📱</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>
|
||||
{d.device_name || 'Unnamed device'}
|
||||
</span>
|
||||
{isMe && (
|
||||
<span style={{
|
||||
padding: '1px 6px', borderRadius: 6,
|
||||
background: '#0d2540', color: '#1d9bf0',
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: 0.5,
|
||||
}}>THIS DEVICE</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
marginTop: 3,
|
||||
}}>
|
||||
{shortAddr(d.x25519_pub_key, 10)}
|
||||
</div>
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||
Linked {new Date(d.added_at * 1000).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
{!isMe && (
|
||||
<button
|
||||
onClick={onUnlink}
|
||||
disabled={unlinking}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #3a2020',
|
||||
color: '#ff6b6b', fontSize: 11, fontWeight: 700,
|
||||
cursor: unlinking ? 'default' : 'pointer',
|
||||
opacity: unlinking ? 0.5 : 1,
|
||||
}}
|
||||
>{unlinking ? '…' : 'Unlink'}</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Link New Device modal ───────────────────────────────────────────────
|
||||
|
||||
function LinkNewDeviceModal({
|
||||
onClose, onLinked,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onLinked: () => void;
|
||||
}): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [key, setKey] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!keyFile) return;
|
||||
const c = code.replace(/\s+/g, '').trim();
|
||||
const k = key.replace(/\s+/g, '').trim().toLowerCase();
|
||||
if (!/^\d{6}$/.test(c)) { setErr('Code must be 6 digits.'); return; }
|
||||
if (!/^[0-9a-f]{64}$/.test(k)) { setErr('Device key must be 64 hex chars.'); return; }
|
||||
const nm = name.trim() || 'New device';
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
// 1. LINK_DEVICE tx → registry learns the new pub.
|
||||
const linkTx = buildLinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: k,
|
||||
deviceName: nm,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(linkTx);
|
||||
// 2. Handshake envelope — encrypt master priv for the new device.
|
||||
const payload = JSON.stringify({
|
||||
v: 1,
|
||||
type: 'pair-handshake',
|
||||
code: c,
|
||||
master_pub: keyFile.pub_key,
|
||||
master_priv: keyFile.priv_key,
|
||||
master_x25519_pub: keyFile.x25519_pub,
|
||||
});
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
payload, keyFile.x25519_priv, k,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: k,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
onLinked();
|
||||
} catch (e) {
|
||||
setErr(humanizeTxError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => !busy && onClose()}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 20,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%', maxWidth: 520, padding: 20, borderRadius: 16,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>
|
||||
Link new device
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose} disabled={busy}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
<Hint>
|
||||
On the new device, tap <b>Pair</b> on the welcome screen and
|
||||
transcribe the 6-digit code and device key from there into the
|
||||
fields below.
|
||||
</Hint>
|
||||
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Field>
|
||||
<Label>6-digit code</Label>
|
||||
<input
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
style={{ ...inputStyle, letterSpacing: 4, textAlign: 'center' }}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Device key (64 hex)</Label>
|
||||
<input
|
||||
value={key}
|
||||
onChange={e => setKey(e.target.value)}
|
||||
placeholder="a1b2c3…"
|
||||
spellCheck={false}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Label>Name (optional)</Label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Alice's laptop"
|
||||
maxLength={64}
|
||||
style={{ ...inputStyle, fontFamily: 'inherit' }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div style={{
|
||||
marginTop: 12, padding: 10, borderRadius: 8,
|
||||
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||
}}>{err}</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
style={{
|
||||
padding: '9px 14px', borderRadius: 999,
|
||||
background: 'transparent', border: '1px solid #1f1f1f',
|
||||
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||
cursor: busy ? 'default' : 'pointer',
|
||||
}}
|
||||
>Cancel</button>
|
||||
<Button onClick={submit} disabled={busy}>{busy ? '…' : 'Link'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ children }: { children: React.ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
159
desktop/src/sections/settings/IdentityPage.tsx
Normal file
159
desktop/src/sections/settings/IdentityPage.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
// Identity settings — pub key, copy, export/import key file, delete account.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { saveKeyFile, wipeAllLocalState } from '@/lib/storage';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
import { PageLayout } from './PageLayout';
|
||||
import { Card, Label, Hint } from './NodePage';
|
||||
|
||||
export function IdentityPage(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
if (!keyFile) return <PageLayout title="Identity"><div>No identity loaded.</div></PageLayout>;
|
||||
|
||||
const copy = async (s: string, label: string) => {
|
||||
await navigator.clipboard.writeText(s);
|
||||
setNotice(`${label} copied`);
|
||||
setTimeout(() => setNotice(null), 1500);
|
||||
};
|
||||
|
||||
const exportKey = async () => {
|
||||
const target = await window.dchain.dialog.saveFile({
|
||||
title: 'Export key file',
|
||||
defaultPath: 'node.json',
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
});
|
||||
if (!target) return;
|
||||
try {
|
||||
await window.dchain.fs.writeText(target, JSON.stringify(keyFile, null, 2));
|
||||
setNotice('Key saved — keep it offline + backed up.');
|
||||
} catch (e) {
|
||||
setNotice(`Export failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const importKey = async () => {
|
||||
const src = await window.dchain.dialog.openFile({
|
||||
title: 'Import key file',
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
if (!src) return;
|
||||
try {
|
||||
const raw = await window.dchain.fs.readText(src);
|
||||
const parsed = JSON.parse(raw) as KeyFile;
|
||||
if (!parsed.pub_key || !parsed.priv_key) throw new Error('not a key file');
|
||||
if (!confirm('Replace the current identity with the imported one? The current identity will be wiped from this device.')) return;
|
||||
await wipeAllLocalState();
|
||||
await saveKeyFile(parsed);
|
||||
setKeyFile(parsed);
|
||||
setNotice('Imported — reload is not needed, new identity active.');
|
||||
} catch (e) {
|
||||
setNotice(`Import failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (!confirm('Delete this identity from this device? Keys are NOT recoverable from the server — export first if you want to keep them.')) return;
|
||||
await wipeAllLocalState();
|
||||
setKeyFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout title="Identity" subtitle="Your Ed25519 master key. Keep it safe — there is no password recovery.">
|
||||
<Card>
|
||||
<Label>Public key (Ed25519, hex)</Label>
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 12, fontFamily: 'monospace',
|
||||
wordBreak: 'break-all', lineHeight: 1.5,
|
||||
}}>
|
||||
{keyFile.pub_key}
|
||||
</div>
|
||||
<ActionRow>
|
||||
<Button onClick={() => copy(keyFile.pub_key, 'Pub key')}>Copy</Button>
|
||||
</ActionRow>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>Device encryption key (X25519, hex)</Label>
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 12, fontFamily: 'monospace',
|
||||
wordBreak: 'break-all', lineHeight: 1.5,
|
||||
}}>
|
||||
{keyFile.x25519_pub}
|
||||
</div>
|
||||
<Hint>
|
||||
Only this device uses this X25519 pair. Sharing the master Ed25519
|
||||
pub (above) is how contacts find you across all your devices.
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>Backup</Label>
|
||||
<ActionRow>
|
||||
<Button onClick={exportKey}>Export key file</Button>
|
||||
<Button onClick={importKey} danger>Import / replace</Button>
|
||||
</ActionRow>
|
||||
<Hint>
|
||||
Exports a JSON file compatible with the mobile client and
|
||||
server's <code>--key</code> flag. The file is <strong>not</strong>
|
||||
encrypted on disk — store it somewhere safe.
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>Danger zone</Label>
|
||||
<ActionRow>
|
||||
<Button onClick={deleteAccount} danger>Delete this identity</Button>
|
||||
</ActionRow>
|
||||
<Hint>
|
||||
Wipes the key, contacts, and chat cache from this device.
|
||||
Without an export, this is irreversible.
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
{notice && (
|
||||
<div style={{
|
||||
padding: 10, borderRadius: 8,
|
||||
background: '#0d2540', color: '#1d9bf0', fontSize: 12,
|
||||
}}>{notice}</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children, onClick, danger, disabled,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
padding: '8px 14px', borderRadius: 999,
|
||||
background: danger ? 'transparent' : '#1d9bf0',
|
||||
border: danger ? '1px solid #3a2020' : 'none',
|
||||
color: danger ? '#ff6b6b' : '#fff',
|
||||
fontSize: 12, fontWeight: 700,
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>{children}</button>
|
||||
);
|
||||
}
|
||||
115
desktop/src/sections/settings/NodePage.tsx
Normal file
115
desktop/src/sections/settings/NodePage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// Node settings page — URL, connection ping-on-commit, token field.
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { getNetStats, setNodeUrl, setApiToken } from '@/lib/api';
|
||||
import { saveSettings } from '@/lib/storage';
|
||||
import { PageLayout } from './PageLayout';
|
||||
|
||||
export function NodePage(): React.ReactElement {
|
||||
const settings = useStore(s => s.settings);
|
||||
const setSettings = useStore(s => s.setSettings);
|
||||
|
||||
const [url, setUrl] = useState(settings.nodeUrl);
|
||||
const [token, setToken] = useState(settings.apiToken ?? '');
|
||||
const [ok, setOk] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => { setUrl(settings.nodeUrl); setToken(settings.apiToken ?? ''); },
|
||||
[settings.nodeUrl, settings.apiToken]);
|
||||
|
||||
const apply = async () => {
|
||||
const clean = url.trim().replace(/\/$/, '');
|
||||
if (!clean) return;
|
||||
setBusy(true); setOk(null);
|
||||
setNodeUrl(clean);
|
||||
setApiToken(token.trim() || null);
|
||||
try {
|
||||
await getNetStats();
|
||||
setOk(true);
|
||||
const next = { nodeUrl: clean, apiToken: token.trim() || undefined };
|
||||
setSettings(next);
|
||||
saveSettings(next);
|
||||
} catch {
|
||||
setOk(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dot = ok === true ? '#3ba55d' : ok === false ? '#f4212e' : '#8b8b8b';
|
||||
|
||||
return (
|
||||
<PageLayout title="Node" subtitle="Which DChain node this client talks to">
|
||||
<Card>
|
||||
<Label>Node URL</Label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 3.5, background: dot }} />
|
||||
<input
|
||||
value={url}
|
||||
onChange={e => { setUrl(e.target.value); setOk(null); }}
|
||||
onBlur={apply}
|
||||
onKeyDown={e => { if (e.key === 'Enter') apply(); }}
|
||||
placeholder="http://node.example:8080"
|
||||
spellCheck={false}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{busy && <span style={{ color: '#8b8b8b', fontSize: 11 }}>…</span>}
|
||||
</div>
|
||||
<Hint>
|
||||
Enter or tab-out to ping. Green dot = `/api/netstats` replied.
|
||||
</Hint>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Label>API token (optional)</Label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
onBlur={apply}
|
||||
placeholder="paste Bearer token if node requires it"
|
||||
spellCheck={false}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<Hint>
|
||||
Some nodes gate writes with DCHAIN_API_TOKEN; leave blank for
|
||||
public ones.
|
||||
</Hint>
|
||||
</Card>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reusable primitives (also imported by Identity / Devices / About) ───
|
||||
|
||||
export function Card({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 14, marginBottom: 14, borderRadius: 12,
|
||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
export function Label({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 8,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
export function Hint({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6, lineHeight: 1.5 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export const inputStyle: React.CSSProperties = {
|
||||
flex: 1, boxSizing: 'border-box',
|
||||
background: '#000', border: '1px solid #1f1f1f',
|
||||
borderRadius: 8, padding: '10px 12px',
|
||||
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||
outline: 'none', width: '100%',
|
||||
};
|
||||
33
desktop/src/sections/settings/PageLayout.tsx
Normal file
33
desktop/src/sections/settings/PageLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// Shared layout for Settings subsection pages — sticky header with the
|
||||
// page title + scroll body. Keeps spacing consistent across Node /
|
||||
// Identity / Devices / About.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export function PageLayout({
|
||||
title, subtitle, children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%', overflowY: 'auto', background: '#000',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'sticky', top: 0, zIndex: 1,
|
||||
padding: '14px 22px', borderBottom: '1px solid #1f1f1f',
|
||||
background: 'rgba(0,0,0,0.9)', backdropFilter: 'blur(6px)',
|
||||
}}>
|
||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 800 }}>{title}</div>
|
||||
{subtitle && (
|
||||
<div style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '18px 22px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
desktop/src/sections/settings/SettingsDetail.tsx
Normal file
18
desktop/src/sections/settings/SettingsDetail.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
// Right-pane content for Settings. Renders by store.settingsPage.
|
||||
|
||||
import React from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { NodePage } from './NodePage';
|
||||
import { IdentityPage } from './IdentityPage';
|
||||
import { DevicesPage } from './DevicesPage';
|
||||
import { AboutPage } from './AboutPage';
|
||||
|
||||
export function SettingsDetail(): React.ReactElement {
|
||||
const page = useStore(s => s.settingsPage);
|
||||
switch (page) {
|
||||
case 'node': return <NodePage />;
|
||||
case 'identity': return <IdentityPage />;
|
||||
case 'devices': return <DevicesPage />;
|
||||
case 'about': return <AboutPage />;
|
||||
}
|
||||
}
|
||||
61
desktop/src/sections/settings/SettingsNav.tsx
Normal file
61
desktop/src/sections/settings/SettingsNav.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// Left-pane category list for Settings. Keeps selection in
|
||||
// store.settingsPage so switching away and back preserves place.
|
||||
|
||||
import React from 'react';
|
||||
import { useStore, type SettingsPage } from '@/lib/store';
|
||||
|
||||
interface Row {
|
||||
key: SettingsPage;
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ key: 'node', label: 'Node', hint: 'URL, connection status' },
|
||||
{ key: 'identity', label: 'Identity', hint: 'Your keys and address' },
|
||||
{ key: 'devices', label: 'Devices', hint: 'Linked devices, pair a new one' },
|
||||
{ key: 'about', label: 'About', hint: 'Version, links' },
|
||||
];
|
||||
|
||||
export function SettingsNav(): React.ReactElement {
|
||||
const page = useStore(s => s.settingsPage);
|
||||
const setPage = useStore(s => s.setSettingsPage);
|
||||
return (
|
||||
<div style={{ padding: 10 }}>
|
||||
{ROWS.map(r => (
|
||||
<NavEntry
|
||||
key={r.key}
|
||||
label={r.label}
|
||||
hint={r.hint}
|
||||
active={page === r.key}
|
||||
onClick={() => setPage(r.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavEntry({
|
||||
label, hint, active, onClick,
|
||||
}: { label: string; hint: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
|
||||
background: active ? '#0a1a29' : 'transparent',
|
||||
border: active ? '1px solid #1d9bf022' : '1px solid transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
color: active ? '#1d9bf0' : '#fff',
|
||||
fontSize: 14, fontWeight: 700,
|
||||
}}>{label}</div>
|
||||
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||
{hint}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { saveSettings } from '@/lib/storage';
|
||||
import { setNodeUrl, getNetStats } from '@/lib/api';
|
||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
||||
// Settings section — two-pane.
|
||||
// List pane: category nav (Node, Identity, Devices, About).
|
||||
// Detail pane: selected category's content.
|
||||
|
||||
export function SettingsList(): React.ReactElement {
|
||||
return (
|
||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<GroupLabel>Node</GroupLabel>
|
||||
<NodeCard />
|
||||
<GroupLabel>Identity</GroupLabel>
|
||||
<IdentityCard />
|
||||
<GroupLabel>About</GroupLabel>
|
||||
<AboutCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsDetail(): React.ReactElement {
|
||||
return (
|
||||
<SectionPlaceholder
|
||||
title="Settings"
|
||||
note="Pick a setting from the list. Devices, notifications, privacy — coming soon."
|
||||
centered
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
||||
letterSpacing: 1.2, textTransform: 'uppercase',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard(): React.ReactElement {
|
||||
const settings = useStore(s => s.settings);
|
||||
const setSettings = useStore(s => s.setSettings);
|
||||
const [url, setUrl] = useState(settings.nodeUrl);
|
||||
const [ok, setOk] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => { setUrl(settings.nodeUrl); }, [settings.nodeUrl]);
|
||||
|
||||
const apply = async () => {
|
||||
const clean = url.trim().replace(/\/$/, '');
|
||||
if (!clean) return;
|
||||
setBusy(true); setOk(null);
|
||||
setNodeUrl(clean);
|
||||
try {
|
||||
await getNetStats();
|
||||
setOk(true);
|
||||
setSettings({ nodeUrl: clean });
|
||||
saveSettings({ nodeUrl: clean });
|
||||
} catch {
|
||||
setOk(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dot = ok === true ? '#3ba55d' : ok === false ? '#f4212e' : '#8b8b8b';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
|
||||
background: '#0a0a0a', display: 'flex', flexDirection: 'column', gap: 8,
|
||||
}}>
|
||||
<label style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
|
||||
NODE URL
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 3.5, background: dot }} />
|
||||
<input
|
||||
value={url}
|
||||
onChange={e => { setUrl(e.target.value); setOk(null); }}
|
||||
onBlur={apply}
|
||||
onKeyDown={e => { if (e.key === 'Enter') apply(); }}
|
||||
placeholder="http://node.example:8080"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
flex: 1, background: '#000',
|
||||
border: '1px solid #1f1f1f', borderRadius: 8,
|
||||
padding: '8px 10px', color: '#fff', fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
/>
|
||||
{busy && <span style={{ fontSize: 11, color: '#8b8b8b' }}>…</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityCard(): React.ReactElement {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
if (!keyFile) return <></>;
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
|
||||
background: '#0a0a0a',
|
||||
}}>
|
||||
<div style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
|
||||
PUB KEY
|
||||
</div>
|
||||
<div className="selectable" style={{
|
||||
color: '#fff', fontSize: 11, fontFamily: 'monospace',
|
||||
marginTop: 4, wordBreak: 'break-all', lineHeight: 1.5,
|
||||
}}>
|
||||
{keyFile.pub_key}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutCard(): React.ReactElement {
|
||||
const [v, setV] = useState<string>('dev');
|
||||
useEffect(() => {
|
||||
window.dchain?.app.version().then(setV).catch(() => {});
|
||||
}, []);
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
|
||||
background: '#0a0a0a', color: '#8b8b8b', fontSize: 12,
|
||||
}}>
|
||||
DChain Desktop v{v}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { SettingsNav as SettingsList } from './SettingsNav';
|
||||
export { SettingsDetail } from './SettingsDetail';
|
||||
|
||||
Reference in New Issue
Block a user