From 221fb4749523140f86702edea4bf82f69ae0899f Mon Sep 17 00:00:00 2001 From: Sisi Date: Tue, 28 Apr 2026 21:22:33 +0200 Subject: [PATCH] add hub get logic and part of client --- client/src/api/http.js | 13 + client/src/api/ws.js | 26 + client/src/components/AuthPage.jsx | 109 +- client/src/components/ChatArea.jsx | 50 + client/src/components/ConnectionItem.jsx | 46 + client/src/components/ConnectionList.jsx | 38 + client/src/components/HubRow.jsx | 13 + client/src/components/MainApp.jsx | 15 +- client/src/components/MessageInput.jsx | 53 + client/src/components/MessageItem.jsx | 47 + client/src/components/MessageList.jsx | 23 + client/src/components/Sidebar.jsx | 13 + client/src/components/UserBar.jsx | 28 + client/src/context/AppContext.jsx | 197 ++++ client/src/utils/color.js | 6 + .../plans/2026-04-28-discord-client.md | 949 ++++++++++++++++++ .../specs/2026-04-28-discord-client-design.md | 120 +++ go-socket | Bin 18526261 -> 18541854 bytes machine-client/index.html | 20 + main.go | 4 +- packages/httpRequest/get.go | 2 +- packages/httpRequest/hubs.go | 31 + packages/httpRequest/user.go | 4 +- packages/types/types.go | 3 +- 24 files changed, 1788 insertions(+), 22 deletions(-) create mode 100644 client/src/api/http.js create mode 100644 client/src/api/ws.js create mode 100644 client/src/components/ChatArea.jsx create mode 100644 client/src/components/ConnectionItem.jsx create mode 100644 client/src/components/ConnectionList.jsx create mode 100644 client/src/components/HubRow.jsx create mode 100644 client/src/components/MessageInput.jsx create mode 100644 client/src/components/MessageItem.jsx create mode 100644 client/src/components/MessageList.jsx create mode 100644 client/src/components/Sidebar.jsx create mode 100644 client/src/components/UserBar.jsx create mode 100644 client/src/context/AppContext.jsx create mode 100644 client/src/utils/color.js create mode 100644 docs/superpowers/plans/2026-04-28-discord-client.md create mode 100644 docs/superpowers/specs/2026-04-28-discord-client-design.md diff --git a/client/src/api/http.js b/client/src/api/http.js new file mode 100644 index 0000000..368f48d --- /dev/null +++ b/client/src/api/http.js @@ -0,0 +1,13 @@ +const BASE = 'http://localhost:8080' + +export async function apiFetch(method, path, { body, query } = {}) { + const token = localStorage.getItem('token') || '' + let url = BASE + path + if (query) url += '?' + new URLSearchParams(query) + const opts = { method, headers: token ? { token } : {} } + if (body) opts.body = new URLSearchParams(body) + const res = await fetch(url, opts) + if (!res.ok) { const errText = await res.text(); throw new Error(`${method} ${path} → ${res.status}: ${errText}`) } + const text = await res.text() + try { return JSON.parse(text) } catch { return text } +} diff --git a/client/src/api/ws.js b/client/src/api/ws.js new file mode 100644 index 0000000..6a4a361 --- /dev/null +++ b/client/src/api/ws.js @@ -0,0 +1,26 @@ +let socket = null +const handlers = {} + +export function wsOnEvent(type, fn) { + handlers[type] = fn +} + +export function wsConnect() { + if (socket) return + const token = localStorage.getItem('token') || '' + socket = new WebSocket('ws://localhost:8080/ws') + socket.onopen = () => socket.send(JSON.stringify({ token })) + socket.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + handlers[msg.type]?.(msg.event) + } catch (err) { console.error('[ws] message error', err) } + } + socket.onclose = () => { socket = null } + socket.onerror = (err) => console.error('[ws] error', err) +} + +export function wsDisconnect() { + socket?.close() + socket = null +} diff --git a/client/src/components/AuthPage.jsx b/client/src/components/AuthPage.jsx index bf8c33d..001d11f 100644 --- a/client/src/components/AuthPage.jsx +++ b/client/src/components/AuthPage.jsx @@ -1,24 +1,101 @@ -import { useNavigate } from "react-router-dom" +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' + +const BASE = 'http://localhost:8080' export default function AuthPage() { - const navigate = useNavigate() + const navigate = useNavigate() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) - function handleLogin(token) { - localStorage.setItem("token", token) - navigate("/") + async function doLogin() { + const res = await fetch(`${BASE}/token`, { + method: 'POST', + body: new URLSearchParams({ username, password }), + }) + if (!res.ok) throw new Error('Invalid credentials') + const { token, userId } = await res.json() + localStorage.setItem('token', token) + localStorage.setItem('userId', userId) + navigate('/') + } + + async function handleLogin() { + if (!username.trim() || !password.trim()) { setError('Username and password are required'); return } + setError('') + setLoading(true) + try { + await doLogin() + } catch { + setError('Invalid credentials') + } finally { + setLoading(false) } + } - return ( -
-
- - -
- - -
-
+ async function handleRegister() { + if (!username.trim() || !password.trim()) { setError('Username and password are required'); return } + setError('') + setLoading(true) + try { + const res = await fetch(`${BASE}/user`, { + method: 'POST', + body: new URLSearchParams({ username, password }), + }) + if (!res.ok) { setError('Registration failed'); return } + await doLogin() + } catch { + setError('Could not reach server') + } finally { + setLoading(false) + } + } + function onKey(e) { + if (e.key === 'Enter' && !loading) handleLogin() + } + + return ( +
+
+

go-socket

+ setUsername(e.target.value)} + onKeyDown={onKey} + autoComplete="username" + /> + setPassword(e.target.value)} + onKeyDown={onKey} + autoComplete="current-password" + /> + {error &&

{error}

} +
+ +
- ) +
+
+ ) } diff --git a/client/src/components/ChatArea.jsx b/client/src/components/ChatArea.jsx new file mode 100644 index 0000000..a4c2a53 --- /dev/null +++ b/client/src/components/ChatArea.jsx @@ -0,0 +1,50 @@ +import { useApp } from '../context/AppContext' +import { colorToCss } from '../utils/color' +import MessageList from './MessageList' +import MessageInput from './MessageInput' + +export default function ChatArea() { + const { state, userId } = useApp() + const conn = state.connections.find(c => c.id === state.selectedId) + + let header = null + if (conn) { + const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId + const other = state.userMap[otherId] + header = ( +
+
+ {other?.name?.[0]?.toUpperCase() ?? '?'} +
+
+

{other?.name ?? '…'}

+ {other?.pronouns && ( +

{other.pronouns}

+ )} +
+ {conn.state === 1 && ( + ● friend + )} +
+ ) + } + + return ( +
+ {header} + {state.selectedId ? ( + <> + + + + ) : ( +
+ Select a conversation +
+ )} +
+ ) +} diff --git a/client/src/components/ConnectionItem.jsx b/client/src/components/ConnectionItem.jsx new file mode 100644 index 0000000..2b8b160 --- /dev/null +++ b/client/src/components/ConnectionItem.jsx @@ -0,0 +1,46 @@ +import { useApp } from '../context/AppContext' +import { colorToCss } from '../utils/color' + +export default function ConnectionItem({ conn }) { + const { state, selectConnection, userId } = useApp() + const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId + const other = state.userMap[otherId] + const unread = state.unread[conn.id] || 0 + const isSelected = state.selectedId === conn.id + const isFriend = conn.state === 1 + + return ( + + ) +} diff --git a/client/src/components/ConnectionList.jsx b/client/src/components/ConnectionList.jsx new file mode 100644 index 0000000..11da4d3 --- /dev/null +++ b/client/src/components/ConnectionList.jsx @@ -0,0 +1,38 @@ +import { useState } from 'react' +import { useApp } from '../context/AppContext' +import ConnectionItem from './ConnectionItem' + +export default function ConnectionList() { + const { state, userId } = useApp() + const [search, setSearch] = useState('') + + const filtered = state.connections.filter(conn => { + if (!search) return true + const otherId = conn.requestorId === userId ? conn.recipientId : conn.requestorId + const other = state.userMap[otherId] + const haystack = (other?.name ?? otherId.slice(0, 8)).toLowerCase() + return haystack.includes(search.toLowerCase()) + }) + + return ( +
+
+ setSearch(e.target.value)} + /> +
+ +
+ {filtered.length === 0 && ( +

No contacts

+ )} + {filtered.map(conn => ( + + ))} +
+
+ ) +} diff --git a/client/src/components/HubRow.jsx b/client/src/components/HubRow.jsx new file mode 100644 index 0000000..80c5427 --- /dev/null +++ b/client/src/components/HubRow.jsx @@ -0,0 +1,13 @@ +export default function HubRow() { + return ( +
+ +
+ ) +} diff --git a/client/src/components/MainApp.jsx b/client/src/components/MainApp.jsx index 7b0b6cb..2a469c9 100644 --- a/client/src/components/MainApp.jsx +++ b/client/src/components/MainApp.jsx @@ -1,3 +1,14 @@ -export default function MainApp({ token }) { - return
Main App
+import { AppProvider } from '../context/AppContext' +import Sidebar from './Sidebar' +import ChatArea from './ChatArea' + +export default function MainApp() { + return ( + +
+ + +
+
+ ) } diff --git a/client/src/components/MessageInput.jsx b/client/src/components/MessageInput.jsx new file mode 100644 index 0000000..24e8dc5 --- /dev/null +++ b/client/src/components/MessageInput.jsx @@ -0,0 +1,53 @@ +import { useState, useRef } from 'react' +import { useApp } from '../context/AppContext' + +export default function MessageInput() { + const { state, sendMessage } = useApp() + const [text, setText] = useState('') + const textareaRef = useRef(null) + + async function handleSend() { + const trimmed = text.trim() + if (!trimmed || !state.selectedId) return + setText('') + try { + await sendMessage(state.selectedId, trimmed) + } catch { + setText(trimmed) + } + textareaRef.current?.focus() + } + + function onKeyDown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + if (!state.selectedId) return null + + return ( +
+
+