feat(frontend): redesign post-login layout into guild/channel/chat panes

This commit is contained in:
nav
2026-05-14 16:38:19 +00:00
parent 4f28f102e0
commit 04d8f9e3bf
4 changed files with 174 additions and 228 deletions

View File

@@ -3,7 +3,6 @@ import ProtectedRoute from './auth/ProtectedRoute'
import AppLayout from './layouts/AppLayout' import AppLayout from './layouts/AppLayout'
import ChatPage from './pages/ChatPage' import ChatPage from './pages/ChatPage'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import WorkspacePage from './pages/WorkspacePage'
export default function App() { export default function App() {
return ( return (
@@ -14,7 +13,7 @@ export default function App() {
path="workspace" path="workspace"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<WorkspacePage /> <ChatPage />
</ProtectedRoute> </ProtectedRoute>
} }
/> />

View File

@@ -76,38 +76,64 @@ a:hover {
text-decoration: underline; text-decoration: underline;
} }
.app-shell {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100vh;
}
.sidebar {
border-right: 1px solid var(--border);
padding: 20px 16px;
text-align: left;
background: color-mix(in srgb, var(--bg) 94%, var(--accent) 6%);
}
.sidebar-title {
margin: 0 0 14px;
}
.sidebar-nav {
display: grid;
gap: 10px;
}
.sidebar-session {
margin-top: 20px;
font-size: 13px;
}
.page-content { .page-content {
padding: 18px; padding: 18px;
text-align: left; text-align: left;
} }
.chat-layout {
display: grid;
gap: 12px;
}
.chat-top-grid {
display: grid;
grid-template-columns: 220px 260px 1fr;
gap: 12px;
min-height: 68vh;
}
.chat-right {
display: grid;
grid-template-rows: 1fr auto;
gap: 12px;
min-height: 0;
}
.chat-history {
overflow: auto;
}
.composer {
display: flex;
gap: 8px;
}
.composer .input {
flex: 1;
}
.footer-actions {
display: flex;
gap: 8px;
}
.list-btn {
width: 100%;
text-align: left;
border: 1px solid var(--border);
background: transparent;
border-radius: 8px;
padding: 8px 10px;
color: var(--text-h);
cursor: pointer;
}
.list-btn.active {
border-color: var(--accent-border);
background: var(--accent-bg);
}
.panel { .panel {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;

View File

@@ -2,33 +2,16 @@ import { Link, Outlet } from 'react-router-dom'
import { useAuth } from '../auth/auth-context' import { useAuth } from '../auth/auth-context'
export default function AppLayout() { export default function AppLayout() {
const { isAuthed, session, logout } = useAuth() const { isAuthed } = useAuth()
return ( return (
<div className="app-shell"> <main className="page-content">
<aside className="sidebar"> {!isAuthed ? (
<h3 className="sidebar-title">Fabric</h3> <nav className="row-wrap" style={{ marginBottom: 12 }}>
<nav className="sidebar-nav">
<Link to="/workspace">Workspace</Link>
<Link to="/chat">Chat</Link>
<Link to="/login">Login</Link> <Link to="/login">Login</Link>
</nav> </nav>
<div className="sidebar-session"> ) : null}
{isAuthed ? (
<>
<div className="muted">{session?.user.email}</div>
<button onClick={() => logout()} className="btn btn-secondary" style={{ marginTop: 8 }}>
Sign out
</button>
</>
) : (
<span className="muted">Not signed in</span>
)}
</div>
</aside>
<main className="page-content">
<Outlet /> <Outlet />
</main> </main>
</div>
) )
} }

View File

@@ -1,7 +1,6 @@
import axios from 'axios' import axios from 'axios'
import { io, type Socket } from 'socket.io-client' import { io, type Socket } from 'socket.io-client'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useAuth } from '../auth/auth-context' import { useAuth } from '../auth/auth-context'
type MessageItem = { type MessageItem = {
@@ -11,35 +10,28 @@ type MessageItem = {
isDeleted?: boolean isDeleted?: boolean
} }
type PageInfo = { type GuildChannel = { id: string; name: string }
nextExpectedSeq?: number
highestCommittedSeq?: number
hasMore?: boolean
}
export default function ChatPage() { export default function ChatPage() {
const { session } = useAuth() const { session, logout } = useAuth()
const [searchParams, setSearchParams] = useSearchParams() const [selectedGuildId, setSelectedGuildId] = useState('')
const guildId = searchParams.get('guildId') ?? '' const [selectedChannelId, setSelectedChannelId] = useState('')
const [channelId, setChannelId] = useState(() => searchParams.get('channelId') ?? '') const [channels, setChannels] = useState<GuildChannel[]>([])
const [content, setContent] = useState('')
const [messages, setMessages] = useState<MessageItem[]>([]) const [messages, setMessages] = useState<MessageItem[]>([])
const [pageInfo, setPageInfo] = useState<PageInfo | null>(null) const [content, setContent] = useState('')
const [socketState, setSocketState] = useState<'offline' | 'online'>('offline')
const [onlineCount, setOnlineCount] = useState(0)
const [typingUsers, setTypingUsers] = useState<string[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [seqFrom, setSeqFrom] = useState('1')
const [seqTo, setSeqTo] = useState('999999')
const [limit, setLimit] = useState('50')
const [editingMessageId, setEditingMessageId] = useState('')
const [editingContent, setEditingContent] = useState('')
const guild = useMemo(() => (session?.guilds ?? []).find((g) => g.nodeId === guildId) ?? null, [session, guildId]) const guilds = session?.guilds ?? []
useEffect(() => {
if (!selectedGuildId && guilds.length) setSelectedGuildId(guilds[0].nodeId)
}, [guilds, selectedGuildId])
const guild = useMemo(() => guilds.find((g) => g.nodeId === selectedGuildId) ?? null, [guilds, selectedGuildId])
const guildToken = useMemo( const guildToken = useMemo(
() => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === guildId)?.token ?? '', () => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === selectedGuildId)?.token ?? '',
[session, guildId], [session, selectedGuildId],
) )
function guildApi() { function guildApi() {
@@ -47,9 +39,7 @@ export default function ChatPage() {
return axios.create({ return axios.create({
baseURL: `${guild.endpoint}/api`, baseURL: `${guild.endpoint}/api`,
timeout: 10000, timeout: 10000,
headers: { headers: { Authorization: `Bearer ${guildToken}` },
Authorization: `Bearer ${guildToken}`,
},
}) })
} }
@@ -57,75 +47,34 @@ export default function ChatPage() {
if (!guild || !guildToken) return null if (!guild || !guildToken) return null
return io(`${guild.endpoint}/realtime`, { return io(`${guild.endpoint}/realtime`, {
transports: ['websocket'], transports: ['websocket'],
auth: { auth: { token: guildToken },
token: guildToken,
},
autoConnect: false, autoConnect: false,
}) })
}, [guild, guildToken]) }, [guild, guildToken])
useEffect(() => { async function loadChannels() {
if (!socket) return if (!guild || !guildToken) return
setError('')
socket.on('connect', () => { try {
setSocketState('online') const res = await guildApi().get('/channels')
if (!channelId) return const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : []
socket.emit('join_channel', { channelId }) setChannels(list)
void guildApi() if (!selectedChannelId && list.length) setSelectedChannelId(list[0].id)
.get(`/channels/${channelId}/messages`, { } catch {
params: { setError('Failed to load channels')
seq_from: Number(seqFrom || '1'), setChannels([])
seq_to: Number(seqTo || '999999'), }
limit: Number(limit || '50'),
},
})
.then((res) => {
setMessages(res.data.items ?? [])
setPageInfo(res.data.page ?? null)
})
})
socket.on('disconnect', () => setSocketState('offline'))
socket.on('presence.online', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
socket.on('presence.offline', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0))
socket.on('typing.start', (e: { channelId?: string; userId?: string }) => {
if (!e.channelId || e.channelId !== channelId || !e.userId || e.userId === session?.user.id) return
setTypingUsers((prev) => (prev.includes(e.userId as string) ? prev : [...prev, e.userId as string]))
})
socket.on('typing.stop', (e: { channelId?: string; userId?: string }) => {
if (!e.channelId || e.channelId !== channelId || !e.userId) return
setTypingUsers((prev) => prev.filter((x) => x !== e.userId))
})
socket.on('message.created', (m: MessageItem) => setMessages((prev) => [...prev, m]))
socket.on('message.updated', (m: MessageItem) =>
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? m : x))),
)
socket.on('message.deleted', (m: MessageItem) =>
setMessages((prev) =>
prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x)),
),
)
socket.connect()
return () => {
socket.removeAllListeners()
socket.disconnect()
} }
}, [socket, channelId, seqFrom, seqTo, limit, guild, guildToken, session?.user.id])
async function pullMessages() { async function pullMessages() {
if (!channelId || !guild || !guildToken) return if (!selectedChannelId || !guild || !guildToken) return
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const res = await guildApi().get(`/channels/${channelId}/messages`, { const res = await guildApi().get(`/channels/${selectedChannelId}/messages`, {
params: { params: { seq_from: 1, seq_to: 999999, limit: 100 },
seq_from: Number(seqFrom || '1'),
seq_to: Number(seqTo || '999999'),
limit: Number(limit || '50'),
},
}) })
setMessages(res.data.items ?? []) setMessages(res.data.items ?? [])
setPageInfo(res.data.page ?? null)
} catch { } catch {
setError('Failed to load messages') setError('Failed to load messages')
} finally { } finally {
@@ -134,117 +83,106 @@ export default function ChatPage() {
} }
async function sendMessage() { async function sendMessage() {
if (!channelId || !content.trim() || !guild || !guildToken) return if (!selectedChannelId || !content.trim() || !guild || !guildToken) return
setError('') setError('')
try { try {
await guildApi().post(`/channels/${channelId}/messages`, { await guildApi().post(`/channels/${selectedChannelId}/messages`, {
content, content,
authorUserId: session?.user.id ?? 'unknown', authorUserId: session?.user.id ?? 'unknown',
}) })
socket?.emit('typing.stop', { channelId })
setContent('') setContent('')
await pullMessages()
} catch { } catch {
setError('Failed to send message') setError('Failed to send message')
} }
} }
async function editMessage() { useEffect(() => {
if (!channelId || !editingMessageId || !editingContent.trim() || !guild || !guildToken) return void loadChannels()
setError('') setMessages([])
try { setSelectedChannelId('')
await guildApi().patch(`/channels/${channelId}/messages/${editingMessageId}`, { }, [selectedGuildId])
content: editingContent,
})
setEditingContent('')
await pullMessages()
} catch {
setError('Failed to edit message')
}
}
async function deleteMessage(messageId: string) {
if (!channelId || !messageId || !guild || !guildToken) return
setError('')
try {
await guildApi().delete(`/channels/${channelId}/messages/${messageId}`)
await pullMessages()
} catch {
setError('Failed to delete message')
}
}
function onChangeChannel(value: string) {
setChannelId(value)
const next = new URLSearchParams(searchParams)
if (guildId) next.set('guildId', guildId)
if (value) next.set('channelId', value)
else next.delete('channelId')
setSearchParams(next)
setTypingUsers([])
}
function onTypingChange(value: string) {
const wasEmpty = !content.trim()
const nowEmpty = !value.trim()
setContent(value)
if (!channelId || !socket) return
if (wasEmpty && !nowEmpty) socket.emit('typing.start', { channelId })
if (!wasEmpty && nowEmpty) socket.emit('typing.stop', { channelId })
}
useEffect(() => { useEffect(() => {
if (!channelId || !socket || !socket.connected) return void pullMessages()
socket.emit('join_channel', { channelId }) }, [selectedChannelId])
useEffect(() => {
if (!socket || !selectedChannelId) return
socket.on('message.created', (m: MessageItem) => setMessages((prev) => [...prev, m]))
socket.on('message.updated', (m: MessageItem) =>
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? m : x))),
)
socket.on('message.deleted', (m: MessageItem) =>
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x))),
)
socket.connect()
socket.emit('join_channel', { channelId: selectedChannelId })
return () => { return () => {
socket.emit('leave_channel', { channelId }) socket.emit('leave_channel', { channelId: selectedChannelId })
socket.removeAllListeners()
socket.disconnect()
} }
}, [channelId, socket, socketState]) }, [socket, selectedChannelId])
return ( return (
<section className="panel"> <section className="chat-layout">
<h2>Chat</h2> <div className="chat-top-grid">
<p className="muted">Guild: {guildId || '-'}</p> <div className="panel col-list">
<p className="muted">Socket: {socketState}</p> <h3>Guilds</h3>
<p className="muted">Online users: {onlineCount}</p> <ul className="list-reset">
{guilds.map((g) => (
<li key={g.nodeId}>
<button className={`list-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`} onClick={() => setSelectedGuildId(g.nodeId)}>
{g.name}
</button>
</li>
))}
</ul>
</div>
<div className="panel col-list">
<h3>Channels</h3>
<ul className="list-reset">
{channels.map((c) => (
<li key={c.id}>
<button className={`list-btn ${selectedChannelId === c.id ? 'active' : ''}`} onClick={() => setSelectedChannelId(c.id)}>
#{c.name}
</button>
</li>
))}
</ul>
</div>
<div className="chat-right">
<div className="panel chat-history">
<h3>Messages</h3>
{loading ? <p className="muted">Loading...</p> : null} {loading ? <p className="muted">Loading...</p> : null}
{error ? <p className="error-text">{error}</p> : null} {error ? <p className="error-text">{error}</p> : null}
{typingUsers.length ? <p className="muted">Typing: {typingUsers.join(', ')}</p> : null}
<div className="row-wrap" style={{ marginBottom: 10 }}>
<input className="input" value={channelId} onChange={(e) => onChangeChannel(e.target.value)} placeholder="Channel ID" />
<input className="input" value={seqFrom} onChange={(e) => setSeqFrom(e.target.value)} placeholder="seq_from" />
<input className="input" value={seqTo} onChange={(e) => setSeqTo(e.target.value)} placeholder="seq_to" />
<input className="input" value={limit} onChange={(e) => setLimit(e.target.value)} placeholder="limit" />
<button className="btn btn-secondary" onClick={pullMessages}>Fetch</button>
</div>
<div className="row-wrap" style={{ marginBottom: 10 }}>
<input className="input" value={content} onChange={(e) => onTypingChange(e.target.value)} placeholder="Type a message" />
<button className="btn" onClick={sendMessage}>Send</button>
</div>
<div className="row-wrap" style={{ marginBottom: 10 }}>
<input className="input" value={editingMessageId} onChange={(e) => setEditingMessageId(e.target.value)} placeholder="messageId" />
<input className="input" value={editingContent} onChange={(e) => setEditingContent(e.target.value)} placeholder="Updated content" />
<button className="btn btn-secondary" onClick={editMessage}>Edit</button>
</div>
<ul className="list-reset"> <ul className="list-reset">
{messages.map((m) => ( {messages.map((m) => (
<li key={m.messageId} className="card" style={{ marginTop: 8 }}> <li key={m.messageId} className="card" style={{ marginTop: 8 }}>
<div> <div>
<strong>#{m.seq}</strong> {m.content} <strong>#{m.seq}</strong> {m.content}
</div> </div>
<div className="muted">{m.messageId}</div>
<button className="btn btn-danger" type="button" onClick={() => deleteMessage(m.messageId)}>
Delete
</button>
</li> </li>
))} ))}
</ul> </ul>
{!loading && !messages.length ? <p className="muted">No messages yet</p> : null} </div>
{pageInfo ? ( <div className="panel composer">
<p> <input className="input" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Type a message" />
next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} | <button className="btn" onClick={sendMessage}>Send</button>
has_more: {String(pageInfo.hasMore ?? false)} </div>
</p> </div>
) : null} </div>
<div className="panel footer-actions">
<button className="btn btn-secondary" onClick={() => void logout()}>Logout</button>
<button className="btn btn-secondary" type="button">Settings</button>
</div>
</section> </section>
) )
} }