feat(frontend): redesign post-login layout into guild/channel/chat panes
This commit is contained in:
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user