From 04d8f9e3bfd920f948815bc6233224ed61fe8e51 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 14 May 2026 16:38:19 +0000 Subject: [PATCH] feat(frontend): redesign post-login layout into guild/channel/chat panes --- src/App.tsx | 3 +- src/index.css | 80 +++++++---- src/layouts/AppLayout.tsx | 31 +--- src/pages/ChatPage.tsx | 288 +++++++++++++++----------------------- 4 files changed, 174 insertions(+), 228 deletions(-) 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}
) }