diff --git a/src/App.tsx b/src/App.tsx
index bf11332..4a55c25 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,7 +3,6 @@ import ProtectedRoute from './auth/ProtectedRoute'
import AppLayout from './layouts/AppLayout'
import ChatPage from './pages/ChatPage'
import LoginPage from './pages/LoginPage'
-import WorkspacePage from './pages/WorkspacePage'
export default function App() {
return (
@@ -14,7 +13,7 @@ export default function App() {
path="workspace"
element={
-
+
}
/>
diff --git a/src/index.css b/src/index.css
index f44c0d6..9d052ed 100644
--- a/src/index.css
+++ b/src/index.css
@@ -76,38 +76,64 @@ a:hover {
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 {
padding: 18px;
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 {
border: 1px solid var(--border);
border-radius: 12px;
diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx
index 7bfbf0b..fa9d91b 100644
--- a/src/layouts/AppLayout.tsx
+++ b/src/layouts/AppLayout.tsx
@@ -2,33 +2,16 @@ import { Link, Outlet } from 'react-router-dom'
import { useAuth } from '../auth/auth-context'
export default function AppLayout() {
- const { isAuthed, session, logout } = useAuth()
+ const { isAuthed } = useAuth()
return (
-
-
-
-
-
-
+ ) : null}
+
+
)
}
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index 9a3c2c7..76f862b 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -1,7 +1,6 @@
import axios from 'axios'
import { io, type Socket } from 'socket.io-client'
import { useEffect, useMemo, useState } from 'react'
-import { useSearchParams } from 'react-router-dom'
import { useAuth } from '../auth/auth-context'
type MessageItem = {
@@ -11,35 +10,28 @@ type MessageItem = {
isDeleted?: boolean
}
-type PageInfo = {
- nextExpectedSeq?: number
- highestCommittedSeq?: number
- hasMore?: boolean
-}
+type GuildChannel = { id: string; name: string }
export default function ChatPage() {
- const { session } = useAuth()
- const [searchParams, setSearchParams] = useSearchParams()
- const guildId = searchParams.get('guildId') ?? ''
- const [channelId, setChannelId] = useState(() => searchParams.get('channelId') ?? '')
- const [content, setContent] = useState('')
+ const { session, logout } = useAuth()
+ const [selectedGuildId, setSelectedGuildId] = useState('')
+ const [selectedChannelId, setSelectedChannelId] = useState('')
+ const [channels, setChannels] = useState([])
const [messages, setMessages] = useState([])
- const [pageInfo, setPageInfo] = useState(null)
- const [socketState, setSocketState] = useState<'offline' | 'online'>('offline')
- const [onlineCount, setOnlineCount] = useState(0)
- const [typingUsers, setTypingUsers] = useState([])
+ const [content, setContent] = useState('')
const [loading, setLoading] = useState(false)
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(
- () => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === guildId)?.token ?? '',
- [session, guildId],
+ () => (session?.guildAccessTokens ?? []).find((x) => x.guildNodeId === selectedGuildId)?.token ?? '',
+ [session, selectedGuildId],
)
function guildApi() {
@@ -47,9 +39,7 @@ export default function ChatPage() {
return axios.create({
baseURL: `${guild.endpoint}/api`,
timeout: 10000,
- headers: {
- Authorization: `Bearer ${guildToken}`,
- },
+ headers: { Authorization: `Bearer ${guildToken}` },
})
}
@@ -57,75 +47,34 @@ export default function ChatPage() {
if (!guild || !guildToken) return null
return io(`${guild.endpoint}/realtime`, {
transports: ['websocket'],
- auth: {
- token: guildToken,
- },
+ auth: { token: guildToken },
autoConnect: false,
})
}, [guild, guildToken])
- useEffect(() => {
- if (!socket) return
-
- socket.on('connect', () => {
- setSocketState('online')
- if (!channelId) return
- socket.emit('join_channel', { channelId })
- void guildApi()
- .get(`/channels/${channelId}/messages`, {
- params: {
- seq_from: Number(seqFrom || '1'),
- 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()
+ async function loadChannels() {
+ if (!guild || !guildToken) return
+ setError('')
+ try {
+ const res = await guildApi().get('/channels')
+ const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : []
+ setChannels(list)
+ if (!selectedChannelId && list.length) setSelectedChannelId(list[0].id)
+ } catch {
+ setError('Failed to load channels')
+ setChannels([])
}
- }, [socket, channelId, seqFrom, seqTo, limit, guild, guildToken, session?.user.id])
+ }
async function pullMessages() {
- if (!channelId || !guild || !guildToken) return
+ if (!selectedChannelId || !guild || !guildToken) return
setLoading(true)
setError('')
try {
- const res = await guildApi().get(`/channels/${channelId}/messages`, {
- params: {
- seq_from: Number(seqFrom || '1'),
- seq_to: Number(seqTo || '999999'),
- limit: Number(limit || '50'),
- },
+ const res = await guildApi().get(`/channels/${selectedChannelId}/messages`, {
+ params: { seq_from: 1, seq_to: 999999, limit: 100 },
})
setMessages(res.data.items ?? [])
- setPageInfo(res.data.page ?? null)
} catch {
setError('Failed to load messages')
} finally {
@@ -134,117 +83,106 @@ export default function ChatPage() {
}
async function sendMessage() {
- if (!channelId || !content.trim() || !guild || !guildToken) return
+ if (!selectedChannelId || !content.trim() || !guild || !guildToken) return
setError('')
try {
- await guildApi().post(`/channels/${channelId}/messages`, {
+ await guildApi().post(`/channels/${selectedChannelId}/messages`, {
content,
authorUserId: session?.user.id ?? 'unknown',
})
- socket?.emit('typing.stop', { channelId })
setContent('')
+ await pullMessages()
} catch {
setError('Failed to send message')
}
}
- async function editMessage() {
- if (!channelId || !editingMessageId || !editingContent.trim() || !guild || !guildToken) return
- setError('')
- try {
- await guildApi().patch(`/channels/${channelId}/messages/${editingMessageId}`, {
- 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(() => {
+ void loadChannels()
+ setMessages([])
+ setSelectedChannelId('')
+ }, [selectedGuildId])
useEffect(() => {
- if (!channelId || !socket || !socket.connected) return
- socket.emit('join_channel', { channelId })
+ void pullMessages()
+ }, [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 () => {
- socket.emit('leave_channel', { channelId })
+ socket.emit('leave_channel', { channelId: selectedChannelId })
+ socket.removeAllListeners()
+ socket.disconnect()
}
- }, [channelId, socket, socketState])
+ }, [socket, selectedChannelId])
return (
-
- Chat
- Guild: {guildId || '-'}
- Socket: {socketState}
- Online users: {onlineCount}
- {loading ? Loading...
: null}
- {error ? {error}
: null}
- {typingUsers.length ? Typing: {typingUsers.join(', ')}
: null}
-
-
onChangeChannel(e.target.value)} placeholder="Channel ID" />
-
setSeqFrom(e.target.value)} placeholder="seq_from" />
-
setSeqTo(e.target.value)} placeholder="seq_to" />
-
setLimit(e.target.value)} placeholder="limit" />
-
+
+
+
+
Guilds
+
+ {guilds.map((g) => (
+ -
+
+
+ ))}
+
+
+
+
+
Channels
+
+ {channels.map((c) => (
+ -
+
+
+ ))}
+
+
+
+
+
+
Messages
+ {loading ?
Loading...
: null}
+ {error ?
{error}
: null}
+
+ {messages.map((m) => (
+ -
+
+ #{m.seq} {m.content}
+
+
+ ))}
+
+
+
+ setContent(e.target.value)} placeholder="Type a message" />
+
+
+
-
-
onTypingChange(e.target.value)} placeholder="Type a message" />
-
+
+
+
+
-
- setEditingMessageId(e.target.value)} placeholder="messageId" />
- setEditingContent(e.target.value)} placeholder="Updated content" />
-
-
-
- {messages.map((m) => (
- -
-
- #{m.seq} {m.content}
-
- {m.messageId}
-
-
- ))}
-
- {!loading && !messages.length ?
No messages yet
: null}
- {pageInfo ? (
-
- next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} |
- has_more: {String(pageInfo.hasMore ?? false)}
-
- ) : null}
)
}