First pass at full implementation
This commit is contained in:
+141
-86
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user