feat: standalone web UI (React + Vite)
React + TypeScript UI on port 5274. Connects to local daemon via WebSocket IPC (DaemonAdapter). Full chat layout with sidebar, message pane, and peer list. Mirrors the TUI feature set. - src/types.ts — IPC types mirroring proto.go - src/adapter/daemon — WebSocket IPC adapter with auto-reconnect - src/store/index.ts — Zustand store; handles all daemon events - src/pages/ — Onboarding (connect + join) and Chat - src/components/ — Sidebar, MessagePane, PeerList Dev: cd web && npm run dev (port 5274) Build: cd web && npm run build Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
68
web/src/components/MessagePane.tsx
Normal file
68
web/src/components/MessagePane.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function MessagePane() {
|
||||
const { messages, activeRoom, activeNetworkId, localPeer, connectedPeers, send } = useWaste()
|
||||
const [draft, setDraft] = useState('')
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const roomMessages = messages[activeRoom] ?? []
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [roomMessages.length])
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const text = draft.trim()
|
||||
if (!text || !activeNetworkId) return
|
||||
|
||||
if (activeRoom.startsWith('dm:')) {
|
||||
// Find peer ID from room name — stored as full ID in room key after "dm:"
|
||||
const toPeerID = activeRoom.slice(3)
|
||||
const peer = connectedPeers.find(p => p.id.startsWith(toPeerID) || p.id === toPeerID)
|
||||
if (peer) {
|
||||
send({ type: 'send_message', network_id: activeNetworkId, room: activeRoom, body: text, to: peer.id })
|
||||
}
|
||||
} else {
|
||||
send({ type: 'send_message', network_id: activeNetworkId, room: activeRoom, body: text })
|
||||
}
|
||||
setDraft('')
|
||||
}
|
||||
|
||||
function aliasFor(fromId: string) {
|
||||
if (fromId === localPeer?.id) return localPeer.alias
|
||||
return connectedPeers.find(p => p.id === fromId)?.alias ?? fromId.slice(0, 8)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="message-pane">
|
||||
<div className="message-pane-header">
|
||||
{activeRoom.startsWith('dm:') ? `@ ${activeRoom.slice(3, 11)}…` : `# ${activeRoom}`}
|
||||
</div>
|
||||
|
||||
<div className="messages">
|
||||
{roomMessages.map((msg, i) => {
|
||||
const mine = msg.from === localPeer?.id
|
||||
return (
|
||||
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}>
|
||||
<span className="message-alias">{aliasFor(msg.from)}</span>
|
||||
<span className="message-text">{msg.text}</span>
|
||||
<span className="message-ts">{new Date(msg.ts).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<form className="compose" onSubmit={submit}>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
placeholder={`Message ${activeRoom}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="submit" disabled={!draft.trim()}>Send</button>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
35
web/src/components/PeerList.tsx
Normal file
35
web/src/components/PeerList.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function PeerList() {
|
||||
const { connectedPeers, activeNetworkId, setActiveRoom, send } = useWaste()
|
||||
|
||||
function openDM(peerId: string) {
|
||||
const room = `dm:${peerId}`
|
||||
setActiveRoom(room)
|
||||
}
|
||||
|
||||
function requestFiles(peerId: string) {
|
||||
send({ type: 'get_file_list', network_id: activeNetworkId ?? undefined, peer_id: peerId })
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="peer-list">
|
||||
<span className="sidebar-label">Peers ({connectedPeers.length})</span>
|
||||
{connectedPeers.map(p => (
|
||||
<div key={p.id} className="peer-entry">
|
||||
<div className="peer-entry-info">
|
||||
<span className="alias">{p.alias}</span>
|
||||
<span className="peer-id">{p.id.slice(0, 8)}…</span>
|
||||
</div>
|
||||
<div className="peer-entry-actions">
|
||||
<button onClick={() => openDM(p.id)} title="Direct message">DM</button>
|
||||
<button onClick={() => requestFiles(p.id)} title="Browse files">Files</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{connectedPeers.length === 0 && (
|
||||
<p className="empty">No peers connected yet</p>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
46
web/src/components/Sidebar.tsx
Normal file
46
web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useWaste } from '../store'
|
||||
|
||||
export function Sidebar() {
|
||||
const { localPeer, networks, activeNetworkId, activeRoom, setActiveRoom, setActiveNetwork, messages } = useWaste()
|
||||
|
||||
// Rooms = general + any DM threads that have messages
|
||||
const rooms = ['general']
|
||||
Object.keys(messages).forEach(r => {
|
||||
if (r.startsWith('dm:') && !rooms.includes(r)) rooms.push(r)
|
||||
})
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
<div className="sidebar-identity">
|
||||
<span className="alias">{localPeer?.alias}</span>
|
||||
<span className="peer-id">{localPeer?.id.slice(0, 8)}…</span>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-label">Networks</span>
|
||||
{networks.map(n => (
|
||||
<button
|
||||
key={n.network_id}
|
||||
className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`}
|
||||
onClick={() => setActiveNetwork(n.network_id)}
|
||||
>
|
||||
{n.network_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<span className="sidebar-label">Rooms</span>
|
||||
{rooms.map(r => (
|
||||
<button
|
||||
key={r}
|
||||
className={`sidebar-item ${r === activeRoom ? 'active' : ''}`}
|
||||
onClick={() => setActiveRoom(r)}
|
||||
>
|
||||
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}…` : `# ${r}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user