Files
waste-go/web/src/store/index.ts
Fredrik Johansson ff14e955ea 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>
2026-06-22 21:57:32 +02:00

145 lines
3.6 KiB
TypeScript

import { create } from 'zustand'
import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage } from '../types'
import { DaemonAdapter } from '../adapter/daemon'
interface WasteState {
// connection
adapter: DaemonAdapter | null
daemonStatus: 'disconnected' | 'connecting' | 'connected'
// identity
localPeer: PeerInfo | null
// networks
networks: NetworkInfo[]
activeNetworkId: string | null
// peers
connectedPeers: PeerInfo[]
// chat — keyed by room
messages: Record<string, ChatMessage[]>
activeRoom: string
// file listings — keyed by peer id
fileLists: Record<string, FileEntry[]>
// actions
connect: (url: string) => void
disconnect: () => void
send: (msg: IpcMessage) => void
setActiveNetwork: (id: string) => void
setActiveRoom: (room: string) => void
handleEvent: (msg: IpcMessage) => void
}
export const useWaste = create<WasteState>((set, get) => ({
adapter: null,
daemonStatus: 'disconnected',
localPeer: null,
networks: [],
activeNetworkId: null,
connectedPeers: [],
messages: {},
activeRoom: 'general',
fileLists: {},
connect(url: string) {
const adapter = new DaemonAdapter(url)
adapter.onStatusChange = (s) => set({ daemonStatus: s })
adapter.on((msg) => get().handleEvent(msg))
adapter.connect()
set({ adapter })
},
disconnect() {
get().adapter?.disconnect()
set({ adapter: null, daemonStatus: 'disconnected' })
},
send(msg) {
get().adapter?.send(msg)
},
setActiveNetwork(id) {
set({ activeNetworkId: id })
},
setActiveRoom(room) {
set({ activeRoom: room })
},
handleEvent(msg) {
switch (msg.type) {
case 'state_snapshot': {
const networks = msg.networks ?? []
set({
localPeer: msg.local_peer ?? null,
networks,
connectedPeers: msg.connected_peers ?? [],
activeNetworkId: networks[0]?.network_id ?? null,
})
break
}
case 'network_joined': {
set(s => ({
networks: s.networks.some(n => n.network_id === msg.network_id)
? s.networks
: [...s.networks, {
network_id: msg.network_id!,
network_name: msg.network_name!,
share_dir: msg.share_dir,
}],
activeNetworkId: s.activeNetworkId ?? msg.network_id!,
}))
break
}
case 'network_left': {
set(s => ({
networks: s.networks.filter(n => n.network_id !== msg.network_id),
activeNetworkId: s.activeNetworkId === msg.network_id
? (s.networks.find(n => n.network_id !== msg.network_id)?.network_id ?? null)
: s.activeNetworkId,
}))
break
}
case 'peer_connected': {
if (msg.peer) {
set(s => ({
connectedPeers: s.connectedPeers.some(p => p.id === msg.peer!.id)
? s.connectedPeers
: [...s.connectedPeers, msg.peer!],
}))
}
break
}
case 'peer_disconnected': {
set(s => ({
connectedPeers: s.connectedPeers.filter(p => p.id !== msg.peer_id),
}))
break
}
case 'message_received': {
if (msg.message) {
const room = msg.message.room
set(s => ({
messages: {
...s.messages,
[room]: [...(s.messages[room] ?? []), msg.message!],
},
}))
}
break
}
case 'file_list': {
if (msg.peer_id && msg.files) {
set(s => ({
fileLists: { ...s.fileLists, [msg.peer_id!]: msg.files! },
}))
}
break
}
}
},
}))