First pass at full implementation

This commit is contained in:
Jon Staab
2026-06-08 15:16:09 -07:00
parent 97cfbd3c22
commit 5545558b0c
11 changed files with 934 additions and 88 deletions
+141 -86
View File
@@ -1,37 +1,18 @@
import { For, Show, createSignal, onMount } from "solid-js"
import { account, logout, view, setView, type View } from "./store"
import { createSignal, Show } from "solid-js"
import { account, logout, view, setView, activeQuorum } from "./store"
import QuorumList from "./components/QuorumList"
import QuorumDetail from "./components/QuorumDetail"
import QuorumChat from "./components/QuorumChat"
import { ProposeQuorum } from "./components/forms/DkgForms"
import { ProposeResharing } from "./components/forms/ResharingForms"
import { ProposeSign } from "./components/forms/SigningForms"
// Placeholder quorum list — will be replaced with real data
const MOCK_QUORUMS: { id: string; name: string }[] = []
function QuorumList() {
return (
<For each={MOCK_QUORUMS} fallback={
<p class="text-xs text-gray-400 dark:text-neutral-500 px-3 py-2">No quora yet</p>
}>
{q => (
<button
class={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
JSON.stringify(view()) === JSON.stringify({ type: "quorum", id: q.id })
? "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700"
}`}
onClick={() => setView({ type: "quorum", id: q.id })}
>
{q.name}
</button>
)}
</For>
)
}
function SidebarContent() {
function SidebarContent(props: { onNew: () => void }) {
const pubkey = () => account()?.pubkey ?? ""
const shortKey = () => `${pubkey().slice(0, 8)}${pubkey().slice(-4)}`
return (
<div class="flex flex-col h-full">
{/* Inbox */}
<div class="px-3 pt-4 pb-2">
<button
class={`w-full text-left px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
@@ -45,26 +26,10 @@ function SidebarContent() {
</button>
</div>
{/* Quorums */}
<div class="px-3 py-2 flex-1 overflow-y-auto">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold text-gray-400 dark:text-neutral-500 uppercase tracking-wider px-3">
Quora
</span>
<button
class="p-1 rounded-md text-gray-400 hover:text-gray-600 dark:text-neutral-500 dark:hover:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 transition-colors"
title="Create quorum"
onClick={() => { /* TODO: open create modal */ }}
>
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<QuorumList />
<QuorumList onNew={props.onNew} />
</div>
{/* User profile + logout */}
<div class="border-t border-gray-200 dark:border-neutral-700 p-3">
<div class="flex items-center gap-2">
<div class="size-8 rounded-full bg-gray-200 dark:bg-neutral-600 shrink-0" />
@@ -86,64 +51,92 @@ function SidebarContent() {
)
}
function MainContent() {
const v = view()
if (v === "inbox") {
return (
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Inbox</h2>
<p class="text-sm text-gray-500 dark:text-neutral-400">No pending items.</p>
</div>
)
}
return (
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Quorum</h2>
<p class="text-sm text-gray-500 dark:text-neutral-400">Quorum detail coming soon.</p>
</div>
)
}
export default function Layout() {
const [drawerOpen, setDrawerOpen] = createSignal(false)
const [showProposeQuorum, setShowProposeQuorum] = createSignal(false)
const [showProposeResharing, setShowProposeResharing] = createSignal(false)
const [showProposeSign, setShowProposeSign] = createSignal(false)
return (
<div class="flex h-screen bg-gray-50 dark:bg-neutral-900 overflow-hidden">
{/* Desktop sidebar */}
<aside class="hidden md:flex flex-col w-64 shrink-0 border-r border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
<div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-700">
<div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-700 flex items-center justify-between">
<span class="text-base font-bold text-gray-900 dark:text-white">NQ</span>
<button
class="p-1 rounded-md text-gray-400 hover:text-gray-600 dark:text-neutral-500 dark:hover:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700 transition-colors"
title="Create quorum"
onClick={() => setShowProposeQuorum(true)}
>
<svg class="size-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<SidebarContent />
<SidebarContent onNew={() => setShowProposeQuorum(true)} />
</aside>
{/* Mobile drawer overlay */}
{/* Mobile drawer backdrop */}
<Show when={drawerOpen()}>
<div
class="fixed inset-0 z-40 bg-black/40 md:hidden"
onClick={() => setDrawerOpen(false)}
/>
<aside class="fixed inset-y-0 left-0 z-50 w-64 flex flex-col bg-white dark:bg-neutral-800 shadow-xl md:hidden">
<div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-700 flex items-center justify-between">
<span class="text-base font-bold text-gray-900 dark:text-white">NQ</span>
<button
class="p-1 rounded-md text-gray-400 hover:text-gray-600"
onClick={() => setDrawerOpen(false)}
>
<svg class="size-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<SidebarContent />
</aside>
</Show>
{/* Main area */}
<div class="flex flex-col flex-1 min-w-0 overflow-hidden">
{/* Mobile top bar */}
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
{/* Mobile drawer — always mounted, slides in/out via transform */}
<aside class={`fixed inset-y-0 left-0 z-50 w-64 flex flex-col bg-white dark:bg-neutral-800 shadow-xl md:hidden transition-transform duration-300 ease-in-out ${drawerOpen() ? "translate-x-0" : "-translate-x-full"}`}>
<div class="px-4 py-4 border-b border-gray-200 dark:border-neutral-700 flex items-center justify-between">
<span class="text-base font-bold text-gray-900 dark:text-white">NQ</span>
<button
class="p-1 rounded-md text-gray-400 hover:text-gray-600"
onClick={() => setDrawerOpen(false)}
>
<svg class="size-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<SidebarContent onNew={() => setShowProposeQuorum(true)} />
</aside>
{/* Desktop: 3-column layout */}
<div class="hidden md:flex flex-1 min-w-0 overflow-hidden">
{/* Col 2: main content */}
<div class="flex-1 min-w-0 overflow-hidden">
<Show when={view() === "inbox"}>
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Inbox</h2>
<p class="text-sm text-gray-500 dark:text-neutral-400">
Pending invitations and requests will appear here.
</p>
</div>
</Show>
<Show when={typeof view() === "object"}>
<QuorumDetail
onProposeSign={() => setShowProposeSign(true)}
onProposeResharing={() => setShowProposeResharing(true)}
/>
</Show>
</div>
{/* Col 3: sessions panel */}
<Show when={activeQuorum()}>
<div class="w-72 shrink-0 border-l border-gray-200 dark:border-neutral-700 flex flex-col">
<div class="px-4 py-3 border-b border-gray-200 dark:border-neutral-700">
<span class="text-sm font-semibold text-gray-700 dark:text-neutral-300">Sessions</span>
</div>
<div class="flex-1 overflow-y-auto">
<QuorumChat />
</div>
</div>
</Show>
</div>
{/* Mobile: main content area */}
<div class="flex md:hidden flex-col flex-1 min-w-0 overflow-hidden">
<header class="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-neutral-700 bg-white dark:bg-neutral-800">
<button
class="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700"
onClick={() => setDrawerOpen(true)}
@@ -157,7 +150,7 @@ export default function Layout() {
<button
class="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700"
title="Create quorum"
onClick={() => { /* TODO: open create modal */ }}
onClick={() => setShowProposeQuorum(true)}
>
<svg class="size-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
@@ -166,9 +159,71 @@ export default function Layout() {
</header>
<main class="flex-1 overflow-y-auto">
<MainContent />
<Show when={view() === "inbox"}>
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Inbox</h2>
<p class="text-sm text-gray-500 dark:text-neutral-400">
Pending invitations and requests will appear here.
</p>
</div>
</Show>
<Show when={typeof view() === "object"}>
<QuorumDetail
onProposeSign={() => setShowProposeSign(true)}
onProposeResharing={() => setShowProposeResharing(true)}
/>
</Show>
</main>
</div>
{/* Modals */}
<Show when={showProposeQuorum()}>
<div
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={() => setShowProposeQuorum(false)}
>
<div
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-w-md p-6"
onClick={e => e.stopPropagation()}
>
<ProposeQuorum onClose={() => setShowProposeQuorum(false)} />
</div>
</div>
</Show>
<Show when={showProposeResharing()}>
<div
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={() => setShowProposeResharing(false)}
>
<div
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-w-md p-6"
onClick={e => e.stopPropagation()}
>
<ProposeResharing
quorumPubkey={activeQuorum()?.quorumPubkey ?? ""}
onClose={() => setShowProposeResharing(false)}
/>
</div>
</div>
</Show>
<Show when={showProposeSign()}>
<div
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={() => setShowProposeSign(false)}
>
<div
class="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl w-full max-w-md p-6"
onClick={e => e.stopPropagation()}
>
<ProposeSign
quorumPubkey={activeQuorum()?.quorumPubkey ?? ""}
onClose={() => setShowProposeSign(false)}
/>
</div>
</div>
</Show>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { For, Show, createMemo } from "solid-js"
import { activeQuorum, dkgSessions, resharingSessions, signingSessions } from "../store"
import type { DkgSession, ResharingSession, SigningSession } from "../models"
export default function QuorumChat() {
const pendingDkg = createMemo<DkgSession[]>(() =>
dkgSessions.filter(s => s.phase === "round1")
)
const pendingSigning = createMemo<SigningSession[]>(() => {
const q = activeQuorum()
return signingSessions.filter(s => s.phase === "round1" && s.quorumPubkey === q?.quorumPubkey)
})
const inProgress = createMemo<Array<{ type: string; phase: string; id: string }>>(() => {
const result: Array<{ type: string; phase: string; id: string }> = []
for (const s of dkgSessions) {
if (s.phase === "round2" || s.phase === "confirming") {
result.push({ type: "DKG", phase: s.phase, id: s.inviteId })
}
}
for (const s of resharingSessions) {
if (s.phase === "round2" || s.phase === "confirming") {
result.push({ type: "Resharing", phase: s.phase, id: s.proposalId })
}
}
for (const s of signingSessions) {
if (s.phase === "round2") {
result.push({ type: "Signing", phase: s.phase, id: s.requestId })
}
}
return result
})
const isEmpty = createMemo(() =>
pendingDkg().length === 0 && pendingSigning().length === 0 && inProgress().length === 0
)
return (
<div class="flex flex-col h-full p-4 gap-4">
<Show when={isEmpty()}>
<div class="flex flex-1 items-center justify-center">
<span class="text-sm text-gray-400 dark:text-neutral-500">No active sessions.</span>
</div>
</Show>
<Show when={pendingDkg().length > 0 || pendingSigning().length > 0}>
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-neutral-400">
Needs Action
</span>
<For each={pendingDkg()}>
{(session: DkgSession) => (
<div class="rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-950 p-3 flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-amber-900 dark:text-amber-200">
DKG Invite
</span>
<span class="text-xs text-amber-700 dark:text-amber-400">
{session.members.length} members · threshold {session.threshold}
</span>
</div>
<div class="flex gap-2">
<button
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={() => console.log("Accept DKG", session.inviteId)}
>
Accept
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={() => console.log("Decline DKG", session.inviteId)}
>
Decline
</button>
</div>
</div>
)}
</For>
<For each={pendingSigning()}>
{(session: SigningSession) => (
<div class="rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-950 p-3 flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium text-amber-900 dark:text-amber-200">
Sign Request
</span>
<span class="text-xs text-amber-700 dark:text-amber-400 font-mono truncate">
{session.msgHex.slice(0, 16)}
</span>
</div>
<div class="flex gap-2">
<button
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={() => console.log("Sign", session.requestId)}
>
Sign
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={() => console.log("Decline signing", session.requestId)}
>
Decline
</button>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={inProgress().length > 0}>
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-neutral-400">
In Progress
</span>
<For each={inProgress()}>
{(item) => (
<div class="rounded-lg border border-gray-200 dark:border-neutral-600 bg-gray-50 dark:bg-neutral-800 p-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-neutral-300">
{item.type}
</span>
<span class="text-xs text-gray-500 dark:text-neutral-400 capitalize">
{item.phase}
</span>
</div>
)}
</For>
</div>
</Show>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { Show, Switch, Match } from "solid-js"
import { activeQuorum, view, setTab } from "../store"
import QuorumLog from "./tabs/QuorumLog"
import QuorumMembers from "./tabs/QuorumMembers"
import QuorumChat from "./QuorumChat"
type Props = {
onProposeSign?: () => void
onProposeResharing?: () => void
}
const TABS = ["log", "members", "chat"] as const
type Tab = (typeof TABS)[number]
export default function QuorumDetail(props: Props) {
return (
<Show
when={activeQuorum()}
fallback={
<div class="flex flex-1 items-center justify-center text-gray-500 dark:text-neutral-400 text-sm">
Select a quorum from the sidebar
</div>
}
>
<div class="flex flex-col h-full">
<div class="px-4 py-3 border-b border-gray-200 dark:border-neutral-700 flex items-start justify-between">
<div>
<div class="text-xs font-medium text-gray-500 dark:text-neutral-400 uppercase tracking-wide mb-1">
Quorum
</div>
<div class="font-mono text-sm text-gray-900 dark:text-white break-all">
{activeQuorum()!.quorumPubkey}
</div>
</div>
<div class="flex items-center gap-2 ml-4 shrink-0">
<button
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={props.onProposeSign}
>
Request Signature
</button>
<button
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={props.onProposeResharing}
>
Rotate Keys
</button>
</div>
</div>
<div class="border-b border-gray-200 dark:border-neutral-700 px-4 flex">
{TABS.map(tab => (
<button
class={[
"py-3 px-4 text-sm font-medium border-b-2 transition-colors",
tab === "chat" ? "md:hidden" : "",
(view() as any).tab === tab
? "border-blue-500 text-blue-600 dark:text-blue-400"
: "border-transparent text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200",
]
.filter(Boolean)
.join(" ")}
onClick={() => setTab(tab as Tab)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
<div class="flex-1 overflow-y-auto">
<Switch>
<Match when={(view() as any).tab === "log"}>
<QuorumLog />
</Match>
<Match when={(view() as any).tab === "members"}>
<QuorumMembers />
</Match>
<Match when={(view() as any).tab === "chat"}>
<div class="md:hidden h-full">
<QuorumChat />
</div>
</Match>
</Switch>
</div>
</div>
</Show>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { For } from "solid-js"
import { quora, view, openQuorum } from "../store"
import type { QuorumRecord } from "../models"
type Props = {
onNew?: () => void
}
export default function QuorumList(props: Props) {
return (
<nav class="flex flex-col h-full">
<div class="flex justify-between items-center px-3 pt-4 pb-2">
<span class="text-xs font-semibold uppercase text-gray-500 dark:text-neutral-400">
Quora
</span>
<button
class="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 text-lg leading-none transition-colors"
onClick={() => props.onNew?.()}
>
+
</button>
</div>
<div class="flex-1 overflow-y-auto px-2">
<For
each={quora}
fallback={
<p class="text-xs text-gray-400 dark:text-neutral-500 px-3 py-2">
No quora yet
</p>
}
>
{(q: QuorumRecord) => {
const isActive = () => {
const v = view()
return typeof v === "object" && v.type === "quorum" && v.id === q.quorumPubkey
}
return (
<button
class={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
isActive()
? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300"
: "text-gray-700 dark:text-neutral-300 hover:bg-gray-100 dark:hover:bg-neutral-700"
}`}
onClick={() => openQuorum(q.quorumPubkey)}
>
<div class="font-medium text-sm">
{q.quorumPubkey.slice(0, 8)}...
</div>
<div class="text-xs opacity-60">
{q.threshold}-of-{q.members.length}
</div>
</button>
)
}}
</For>
</div>
</nav>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { createSignal } from "solid-js"
import { Show } from "solid-js"
import type { DkgSession } from "../../models"
const inputClass =
"w-full px-3 py-2 text-sm border border-gray-200 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
const primaryButtonClass =
"px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
const secondaryButtonClass =
"px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
export function ProposeQuorum(props: { onClose: () => void }) {
const [pubkeysText, setPubkeysText] = createSignal("")
const [threshold, setThreshold] = createSignal(2)
const [error, setError] = createSignal("")
function handleSubmit(e: Event) {
e.preventDefault()
const pubkeys = pubkeysText()
.split("\n")
.map(line => line.trim())
.filter(line => line.length > 0)
if (pubkeys.length < 2) {
setError("At least 2 members are required.")
return
}
const t = threshold()
if (t < 1 || t > pubkeys.length) {
setError(`Threshold must be between 1 and ${pubkeys.length}.`)
return
}
setError("")
console.log("ProposeQuorum", { pubkeys, threshold: t })
props.onClose()
}
return (
<form onSubmit={handleSubmit} class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Create Quorum</h2>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">
Member Pubkeys
</label>
<textarea
class={inputClass}
rows={6}
placeholder="One pubkey per line"
value={pubkeysText()}
onInput={e => setPubkeysText(e.currentTarget.value)}
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">
Threshold
</label>
<input
type="number"
class={inputClass}
min={1}
value={threshold()}
onInput={e => setThreshold(Number(e.currentTarget.value))}
/>
</div>
<Show when={error()}>
<p class="text-sm text-red-600 dark:text-red-400">{error()}</p>
</Show>
<div class="flex gap-2 justify-end">
<button type="button" class={secondaryButtonClass} onClick={props.onClose}>
Cancel
</button>
<button type="submit" class={primaryButtonClass}>
Create
</button>
</div>
</form>
)
}
export function DkgInviteResponse(props: { session: DkgSession; onClose: () => void }) {
const [reason, setReason] = createSignal("")
function handleAccept() {
console.log("DkgInviteResponse accept", { inviteId: props.session.inviteId })
props.onClose()
}
function handleDecline() {
console.log("DkgInviteResponse decline", {
inviteId: props.session.inviteId,
reason: reason(),
})
props.onClose()
}
return (
<div class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">DKG Invite</h2>
<div class="text-sm text-gray-700 dark:text-neutral-300 flex flex-col gap-1">
<p>
You have been invited to participate in a distributed key generation ceremony with{" "}
<span class="font-medium">{props.session.members.length}</span> members and a threshold
of{" "}
<span class="font-medium">{props.session.threshold}</span>.
</p>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-neutral-300">
Reason (optional, shown if declining)
</label>
<textarea
class={inputClass}
rows={3}
placeholder="Optional reason..."
value={reason()}
onInput={e => setReason(e.currentTarget.value)}
/>
</div>
<div class="flex gap-2 justify-end">
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={handleDecline}
>
Decline
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors"
onClick={handleAccept}
>
Accept
</button>
</div>
</div>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { createSignal, Show } from "solid-js"
import type { ResharingSession } from "../../models"
export function ProposeResharing(props: { quorumPubkey: string; onClose: () => void }) {
const [newMembersText, setNewMembersText] = createSignal("")
const [newThreshold, setNewThreshold] = createSignal(2)
const [error, setError] = createSignal("")
function handleSubmit(e: Event) {
e.preventDefault()
setError("")
const lines = newMembersText()
.split("\n")
.map(l => l.trim())
.filter(l => l.length > 0)
if (lines.length === 0) {
setError("Enter at least one member pubkey.")
return
}
const t = newThreshold()
if (t < 1 || t > lines.length) {
setError("Threshold must be between 1 and the number of new members.")
return
}
console.log("ProposeResharing", {
quorumPubkey: props.quorumPubkey,
newMembers: lines,
newThreshold: t,
})
props.onClose()
}
return (
<div class="space-y-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Propose Key Rotation</h2>
<p class="text-sm italic text-gray-500 dark:text-neutral-400">
Current members will contribute their shares to bootstrap the new quorum.
</p>
<form onSubmit={handleSubmit} class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-1">
New Members (one pubkey per line)
</label>
<textarea
rows={5}
value={newMembersText()}
onInput={e => setNewMembersText(e.currentTarget.value)}
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-1">
New Threshold
</label>
<input
type="number"
min={1}
value={newThreshold()}
onInput={e => setNewThreshold(Number(e.currentTarget.value))}
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<Show when={error()}>
<p class="text-sm text-red-600 dark:text-red-400">{error()}</p>
</Show>
<div class="flex gap-2 justify-end">
<button
type="button"
onClick={props.onClose}
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
>
Propose
</button>
</div>
</form>
</div>
)
}
export function ResharingInviteResponse(props: { session: ResharingSession; onClose: () => void }) {
const truncated = props.session.quorumPubkey.slice(0, 8) + "..." + props.session.quorumPubkey.slice(-8)
function handleParticipate() {
console.log("ResharingInviteResponse: participate", props.session.proposalId)
props.onClose()
}
function handleDecline() {
console.log("ResharingInviteResponse: decline", props.session.proposalId)
props.onClose()
}
return (
<div class="space-y-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Resharing Proposal</h2>
<div class="space-y-2 text-sm text-gray-700 dark:text-neutral-300">
<div>
<span class="font-medium">Quorum: </span>
<span class="font-mono">{truncated}</span>
</div>
<div>
<span class="font-medium">New member count: </span>
<span>{props.session.newMembers.length}</span>
</div>
<div>
<span class="font-medium">New threshold: </span>
<span>{props.session.newThreshold}</span>
</div>
</div>
<div class="flex gap-2 justify-end">
<button
type="button"
onClick={handleDecline}
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
>
Decline
</button>
<button
type="button"
onClick={handleParticipate}
class="px-4 py-2 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors"
>
Participate
</button>
</div>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { createSignal } from "solid-js"
import type { SigningSession } from "../../models"
export function ProposeSign(props: { quorumPubkey: string; onClose: () => void }) {
const [message, setMessage] = createSignal("")
const [error, setError] = createSignal("")
function handleSubmit() {
if (!message().trim()) {
setError("Message is required.")
return
}
setError("")
console.log({ quorumPubkey: props.quorumPubkey, message: message() })
props.onClose()
}
return (
<div class="flex flex-col gap-4">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Request Signature</h2>
<textarea
rows={4}
class="w-full px-3 py-2 text-sm border border-gray-200 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
value={message()}
onInput={e => setMessage(e.currentTarget.value)}
/>
{error() && (
<p class="text-sm text-red-600 dark:text-red-400">{error()}</p>
)}
<div class="flex gap-2 justify-end">
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={props.onClose}
>
Cancel
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
onClick={handleSubmit}
>
Request Signature
</button>
</div>
</div>
)
}
export function SignRequestResponse(props: { session: SigningSession; onClose: () => void }) {
return (
<div class="flex flex-col gap-4">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Signature Request</h2>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-neutral-400">Message hash</span>
<span class="font-mono text-sm text-gray-900 dark:text-white">
{props.session.msgHex.slice(0, 16)}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-neutral-400">Signing set</span>
<span class="text-sm text-gray-900 dark:text-white">
{props.session.signingSet.length} members
</span>
</div>
<div class="flex gap-2 justify-end">
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={props.onClose}
>
Decline
</button>
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 transition-colors"
onClick={props.onClose}
>
Sign
</button>
</div>
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { createSignal, onMount, For } from "solid-js"
import { activeQuorum } from "../../store"
import { getEventsByTag } from "../../storage"
import type { NostrEvent } from "applesauce-core/helpers/event"
function kindLabel(kind: number): string {
if (kind === 7050) { return "DKG Invite" }
if (kind === 7051) { return "DKG Round 1" }
if (kind === 7052) { return "DKG Round 2" }
if (kind === 7053) { return "DKG Complete" }
if (kind === 7054) { return "Resharing Proposed" }
if (kind === 7055) { return "Resharing Round 1" }
if (kind === 7056) { return "Resharing Round 2" }
if (kind === 7057) { return "Rotation Complete" }
if (kind === 7058) { return "Sign Request" }
if (kind === 7059) { return "Signing Round 1" }
if (kind === 7060) { return "Signing Round 2" }
if (kind === 7061) { return "Declined" }
return `Event ${kind}`
}
function relativeTime(createdAt: number): string {
const now = Math.floor(Date.now() / 1000)
const age = now - createdAt
if (age < 60) { return `${age}s ago` }
if (age < 3600) { return `${Math.floor(age / 60)}m ago` }
if (age < 86400) { return `${Math.floor(age / 3600)}h ago` }
return `${Math.floor(age / 86400)}d ago`
}
export default function QuorumLog() {
const [events, setEvents] = createSignal<NostrEvent[]>([])
onMount(async () => {
const quorum = activeQuorum()
if (!quorum) { return }
const fetched = await getEventsByTag("p", quorum.quorumPubkey)
setEvents(fetched.slice().sort((a, b) => b.created_at - a.created_at))
})
return (
<div class="p-4">
<For
each={events()}
fallback={<p class="text-sm text-gray-400 dark:text-neutral-500">No events yet.</p>}
>
{(event) => (
<div class="flex items-center justify-between py-2 border-b border-gray-100 dark:border-neutral-700 last:border-b-0">
<div class="flex flex-col">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{kindLabel(event.kind)}
</span>
<span class="text-xs text-gray-400 dark:text-neutral-500 font-mono">
{event.id.slice(0, 8)}
</span>
</div>
<span class="text-xs text-gray-400 dark:text-neutral-500 ml-4 shrink-0">
{relativeTime(event.created_at)}
</span>
</div>
)}
</For>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { createSignal, Show, For } from "solid-js"
import { activeQuorum } from "../../store"
import type { QuorumMember } from "../../protocol"
export default function QuorumMembers() {
const [copiedIndex, setCopiedIndex] = createSignal<number | null>(null)
function copyPubkey(m: QuorumMember) {
navigator.clipboard.writeText(m.pubkey)
setCopiedIndex(m.index)
setTimeout(() => setCopiedIndex(null), 1500)
}
return (
<div class="p-4">
<Show when={activeQuorum()}>
{(quorum) => (
<div class="space-y-4">
<div>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
{quorum().threshold}-of-{quorum().members.length} signatures required
</span>
</div>
<div class="space-y-2">
<For each={quorum().members}>
{(member) => (
<div class="flex items-center gap-3 px-3 py-2 rounded-lg border border-gray-200 dark:border-neutral-600 bg-white dark:bg-neutral-800">
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center text-sm font-medium">
{member.index}
</div>
<span class="flex-1 text-sm font-mono text-gray-700 dark:text-neutral-300 truncate">
{member.pubkey.slice(0, 12)}...{member.pubkey.slice(-8)}
</span>
<button
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-neutral-600 text-gray-700 dark:text-neutral-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition-colors"
onClick={() => copyPubkey(member)}
>
{copiedIndex() === member.index ? "Copied!" : "Copy"}
</button>
</div>
)}
</For>
</div>
</div>
)}
</Show>
</div>
)
}
+4
View File
@@ -1,3 +1,7 @@
@import "tailwindcss";
@source "../node_modules/preline/dist/*.js";
@import "../node_modules/preline/variants.css";
@layer base {
button, [role="tab"] { cursor: pointer; }
}
+25 -2
View File
@@ -1,15 +1,17 @@
import { createSignal, createEffect } from "solid-js"
import { createSignal, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { AccountManager } from "applesauce-accounts"
import { registerCommonAccountTypes } from "applesauce-accounts/accounts/common"
import type { IAccount } from "applesauce-accounts"
import { parseJson } from "@welshman/lib"
import { toast } from "solid-toast"
import { subscribeInbox, INBOX_RELAYS } from "./nostr"
import type { QuorumRecord, DkgSession, ResharingSession, SigningSession } from "./models"
// Shape of the object returned by Observable.subscribe()
type Subscription = { unsubscribe(): void }
export type View = "inbox" | { type: "quorum"; id: string }
export type View = "inbox" | { type: "quorum"; id: string; tab: "log" | "members" | "chat" }
const ACCOUNTS_KEY = "nq:accounts"
const ACTIVE_KEY = "nq:active"
@@ -56,6 +58,27 @@ export function logout() {
export const [view, setView] = createSignal<View>("inbox")
export function openQuorum(id: string): void {
setView({ type: "quorum", id, tab: "log" })
}
export function setTab(tab: "log" | "members" | "chat"): void {
setView(v => (typeof v === "object" && v.type === "quorum") ? { ...v, tab } : v)
}
export const [quora, setQuora] = createStore<QuorumRecord[]>([])
export const [dkgSessions, setDkgSessions] = createStore<DkgSession[]>([])
export const [resharingSessions, setResharingSessions] = createStore<ResharingSession[]>([])
export const [signingSessions, setSigningSessions] = createStore<SigningSession[]>([])
export const activeQuorum = createMemo<QuorumRecord | undefined>(() => {
const v = view()
if (typeof v === "object" && v.type === "quorum") {
return quora.find(q => q.quorumPubkey === v.id)
}
return undefined
})
// Inbox subscription — one active subscription at a time
let inboxSub: Subscription | null = null