Compare commits

..

7 Commits

10 changed files with 540 additions and 238 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

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } from '../lib/auth-storage' import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } from '../lib/auth-storage'
import type { AuthSession } from '../lib/auth-storage' import type { AuthSession } from '../lib/auth-storage'
import { loginCenter, logoutCenter, refreshCenter } from '../lib/center-auth-client' import { loginCenter, logoutCenter, meGuildsCenter, refreshCenter } from '../lib/center-auth-client'
import { AuthContext } from './auth-context' import { AuthContext } from './auth-context'
import type { AuthContextValue } from './auth-context' import type { AuthContextValue } from './auth-context'
@@ -13,15 +13,15 @@ export function AuthProvider({ children }: PropsWithChildren) {
() => ({ () => ({
session, session,
isAuthed: !!session, isAuthed: !!session,
login: async (centerApiBase: string, centerApiKey: string, email: string, password: string) => { login: async (centerApiBase: string, email: string, password: string) => {
const next = await loginCenter(centerApiBase, centerApiKey, { email, password }) const next = await loginCenter(centerApiBase, { email, password })
setAuthSession(next) setAuthSession(next)
setSession(next) setSession(next)
}, },
logout: async () => { logout: async () => {
if (session?.refreshToken) { if (session?.refreshToken) {
try { try {
await logoutCenter(session.centerApiBase, session.centerApiKey, session.refreshToken) await logoutCenter(session.centerApiBase, session.refreshToken)
} catch { } catch {
// noop // noop
} }
@@ -33,17 +33,45 @@ export function AuthProvider({ children }: PropsWithChildren) {
if (!session) return null if (!session) return null
if (!isAccessTokenStale(session.accessToken)) return session.accessToken if (!isAccessTokenStale(session.accessToken)) return session.accessToken
const refreshed = await refreshCenter(session.centerApiBase, session.centerApiKey, session.refreshToken) const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken)
const next: AuthSession = { const next: AuthSession = {
...session, ...session,
accessToken: refreshed.accessToken, accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken, refreshToken: refreshed.refreshToken,
tokenType: refreshed.tokenType, tokenType: refreshed.tokenType,
expiresIn: refreshed.expiresIn,
} }
setAuthSession(next) setAuthSession(next)
setSession(next) setSession(next)
return next.accessToken return next.accessToken
}, },
refreshGuilds: async () => {
if (!session) return
let accessToken = session.accessToken
let refreshToken = session.refreshToken
let tokenType = session.tokenType
let expiresIn = session.expiresIn
if (isAccessTokenStale(session.accessToken)) {
const refreshed = await refreshCenter(session.centerApiBase, session.refreshToken)
accessToken = refreshed.accessToken
refreshToken = refreshed.refreshToken
tokenType = refreshed.tokenType
expiresIn = refreshed.expiresIn
}
const guildData = await meGuildsCenter(session.centerApiBase, accessToken)
const next: AuthSession = {
...session,
accessToken,
refreshToken,
tokenType,
expiresIn,
guilds: guildData.guilds,
guildAccessTokens: guildData.guildAccessTokens,
}
setAuthSession(next)
setSession(next)
},
}), }),
[session], [session],
) )

View File

@@ -4,9 +4,10 @@ import type { AuthSession } from '../lib/auth-storage'
export type AuthContextValue = { export type AuthContextValue = {
session: AuthSession | null session: AuthSession | null
isAuthed: boolean isAuthed: boolean
login: (centerApiBase: string, centerApiKey: string, email: string, password: string) => Promise<void> login: (centerApiBase: string, email: string, password: string) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>
ensureFreshToken: () => Promise<string | null> ensureFreshToken: () => Promise<string | null>
refreshGuilds: () => Promise<void>
} }
export const AuthContext = createContext<AuthContextValue | null>(null) export const AuthContext = createContext<AuthContextValue | null>(null)

View File

@@ -64,6 +64,209 @@
body { body {
margin: 0; margin: 0;
background: linear-gradient(180deg, rgba(170, 59, 255, 0.08), transparent 220px) no-repeat var(--bg);
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.page-content {
padding: 18px;
text-align: left;
}
.chat-layout {
display: grid;
gap: 12px;
}
.chat-top-grid {
display: grid;
grid-template-columns: 220px 260px 1fr 220px;
gap: 12px;
min-height: 68vh;
}
.chat-top-grid.hide-v1 {
grid-template-columns: 0 260px 1fr 220px;
}
.chat-top-grid.hide-v2 {
grid-template-columns: 220px 0 1fr 220px;
}
.chat-top-grid.hide-v4 {
grid-template-columns: 220px 260px 1fr 0;
}
.chat-top-grid.hide-v1.hide-v2 {
grid-template-columns: 0 0 1fr 220px;
}
.chat-top-grid.hide-v1.hide-v4 {
grid-template-columns: 0 260px 1fr 0;
}
.chat-top-grid.hide-v2.hide-v4 {
grid-template-columns: 220px 0 1fr 0;
}
.chat-top-grid.hide-v1.hide-v2.hide-v4 {
grid-template-columns: 0 0 1fr 0;
}
.chat-top-grid.hide-v1 .col-v1,
.chat-top-grid.hide-v2 .col-v2,
.chat-top-grid.hide-v4 .col-v4 {
display: none;
}
.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;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: grid;
place-items: center;
z-index: 30;
}
.modal-card {
width: min(560px, 92vw);
max-height: 80vh;
overflow: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
}
.modal-list {
display: grid;
gap: 8px;
margin-top: 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;
padding: 16px;
box-shadow: var(--shadow);
background: var(--bg);
}
.form-grid {
display: grid;
gap: 10px;
}
.row-wrap {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.input {
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
min-width: 120px;
background: transparent;
color: var(--text-h);
}
.input:focus {
outline: 2px solid var(--accent-border);
outline-offset: 1px;
}
.btn {
border: 1px solid var(--accent-border);
background: var(--accent);
color: #fff;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
}
.btn-secondary {
background: transparent;
color: var(--text-h);
}
.btn-danger {
background: #dc2626;
border-color: #dc2626;
}
.muted {
color: var(--text);
}
.error-text {
color: #dc2626;
margin-top: 10px;
}
.list-reset {
list-style: none;
padding: 0;
margin: 0;
}
.card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
display: grid;
gap: 8px;
background: color-mix(in srgb, var(--bg) 95%, var(--accent) 5%);
} }
h1, h1,

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 style={{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '100vh' }}> <main className="page-content">
<aside style={{ borderRight: '1px solid #ddd', padding: 16 }}> {!isAuthed ? (
<h3>Fabric</h3> <nav className="row-wrap" style={{ marginBottom: 12 }}>
<nav style={{ display: 'grid', gap: 8 }}> <Link to="/login">Login</Link>
<Link to="/workspace"></Link>
<Link to="/chat"></Link>
<Link to="/login"></Link>
</nav> </nav>
<div style={{ marginTop: 16, fontSize: 12 }}> ) : null}
{isAuthed ? (
<>
<div>{session?.user.email}</div>
<button onClick={() => logout()} style={{ marginTop: 6 }}>
</button>
</>
) : (
<span></span>
)}
</div>
</aside>
<main style={{ padding: 16 }}>
<Outlet /> <Outlet />
</main> </main>
</div>
) )
} }

View File

@@ -1,9 +1,9 @@
export type AuthSession = { export type AuthSession = {
centerApiBase: string centerApiBase: string
centerApiKey: string
accessToken: string accessToken: string
refreshToken: string refreshToken: string
tokenType: string tokenType: string
expiresIn?: number
user: { user: {
id: string id: string
email: string email: string
@@ -18,6 +18,7 @@ export type AuthSession = {
guildNodeId: string guildNodeId: string
token: string token: string
tokenType: string tokenType: string
expiresIn?: number
}> }>
} }

View File

@@ -4,22 +4,28 @@ import type { AuthSession } from './auth-storage'
export type LoginPayload = { email: string; password: string } export type LoginPayload = { email: string; password: string }
type LoginResponse = { type LoginResponse = {
centerApiBase: string
accessToken: string accessToken: string
refreshToken: string refreshToken: string
tokenType: string tokenType: string
expiresIn?: number
user: { id: string; email: string } user: { id: string; email: string }
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }> guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string }> guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string; expiresIn?: number }>
} }
type RefreshResponse = { type RefreshResponse = {
accessToken: string accessToken: string
refreshToken: string refreshToken: string
tokenType: string tokenType: string
expiresIn?: number
} }
function centerClient(centerApiBase: string, centerApiKey: string) { type MeGuildsResponse = {
guilds: Array<{ nodeId: string; name: string; endpoint: string; status: 'active' | 'offline' | 'revoked' }>
guildAccessTokens: Array<{ guildNodeId: string; token: string; tokenType: string; expiresIn?: number }>
}
function centerClient(centerApiBase: string) {
const client = axios.create({ const client = axios.create({
baseURL: centerApiBase, baseURL: centerApiBase,
timeout: 10000, timeout: 10000,
@@ -27,7 +33,6 @@ function centerClient(centerApiBase: string, centerApiKey: string) {
client.interceptors.request.use((request) => { client.interceptors.request.use((request) => {
const requestId = crypto.randomUUID() const requestId = crypto.randomUUID()
request.headers['x-api-key'] = centerApiKey
request.headers['x-request-id'] = requestId request.headers['x-request-id'] = requestId
request.headers['x-client-name'] = 'fabric-frontend' request.headers['x-client-name'] = 'fabric-frontend'
return request return request
@@ -36,16 +41,43 @@ function centerClient(centerApiBase: string, centerApiKey: string) {
return client return client
} }
export async function loginCenter(centerApiBase: string, centerApiKey: string, payload: LoginPayload): Promise<AuthSession> { export async function loginCenter(centerApiBase: string, payload: LoginPayload): Promise<AuthSession> {
const res = await centerClient(centerApiBase, centerApiKey).post<LoginResponse>('/auth/login', payload) const res = await centerClient(centerApiBase).post<LoginResponse>('/auth/login', payload)
return { ...res.data, centerApiBase, centerApiKey } return { ...res.data, centerApiBase }
} }
export async function refreshCenter(centerApiBase: string, centerApiKey: string, refreshToken: string): Promise<RefreshResponse> { export async function refreshCenter(centerApiBase: string, refreshToken: string): Promise<RefreshResponse> {
const res = await centerClient(centerApiBase, centerApiKey).post<RefreshResponse>('/auth/refresh', { refreshToken }) const res = await centerClient(centerApiBase).post<RefreshResponse>('/auth/refresh', { refreshToken })
return res.data return res.data
} }
export async function logoutCenter(centerApiBase: string, centerApiKey: string, refreshToken: string): Promise<void> { export async function logoutCenter(centerApiBase: string, refreshToken: string): Promise<void> {
await centerClient(centerApiBase, centerApiKey).post('/auth/logout', { refreshToken }) await centerClient(centerApiBase).post('/auth/logout', { refreshToken })
}
export async function meGuildsCenter(centerApiBase: string, accessToken: string): Promise<MeGuildsResponse> {
const res = await centerClient(centerApiBase).get<MeGuildsResponse>('/auth/me/guilds', {
headers: { Authorization: `Bearer ${accessToken}` },
})
return res.data
}
export async function joinGuildCenter(centerApiBase: string, accessToken: string, guildNodeId: string): Promise<void> {
await centerClient(centerApiBase).post(
'/auth/me/guilds/join',
{ guildNodeId },
{ headers: { Authorization: `Bearer ${accessToken}` } },
)
}
export async function guildMembersCenter(
centerApiBase: string,
accessToken: string,
guildNodeId: string,
): Promise<Array<{ userId: string; email: string; status: string }>> {
const res = await centerClient(centerApiBase).get<Array<{ userId: string; email: string; status: string }>>(
`/auth/guilds/${encodeURIComponent(guildNodeId)}/members`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
)
return Array.isArray(res.data) ? res.data : []
} }

View File

@@ -1,8 +1,8 @@
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'
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
type MessageItem = { type MessageItem = {
messageId: string messageId: string
@@ -11,35 +11,38 @@ type MessageItem = {
isDeleted?: boolean isDeleted?: boolean
} }
type PageInfo = { type GuildChannel = { id: string; name: string; guildId?: string }
nextExpectedSeq?: number type MemberItem = { userId: string; email: string; status: string }
highestCommittedSeq?: number
hasMore?: boolean
}
export default function ChatPage() { export default function ChatPage() {
const { session } = useAuth() const { session, logout, ensureFreshToken, refreshGuilds } = 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 [guildDbId, setGuildDbId] = useState('')
const [members, setMembers] = useState<MemberItem[]>([])
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 [newChannelName, setNewChannelName] = useState('')
const [onlineCount, setOnlineCount] = useState(0) const [joinGuildNodeId, setJoinGuildNodeId] = useState('')
const [typingUsers, setTypingUsers] = useState<string[]>([]) const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
const [showGuilds, setShowGuilds] = useState(true)
const [showChannels, setShowChannels] = useState(true)
const [showMembers, setShowMembers] = useState(true)
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 +50,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,190 +58,245 @@ 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', { params: guildDbId ? { guildId: guildDbId } : undefined })
if (!channelId) return const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : []
socket.emit('join_channel', { channelId }) setChannels(list)
void guildApi() if (!guildDbId && list[0]?.guildId) setGuildDbId(list[0].guildId)
.get(`/channels/${channelId}/messages`, { if (!selectedChannelId && list.length) setSelectedChannelId(list[0].id)
params: { } catch {
seq_from: Number(seqFrom || '1'), setError('Failed to load channels')
seq_to: Number(seqTo || '999999'), setChannels([])
limit: Number(limit || '50'), }
}, }
})
.then((res) => { async function loadMembers() {
setMessages(res.data.items ?? []) if (!session || !selectedGuildId) return
setPageInfo(res.data.page ?? null) setError('')
}) try {
}) const token = await ensureFreshToken()
socket.on('disconnect', () => setSocketState('offline')) if (!token) return
socket.on('presence.online', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0)) const list = await guildMembersCenter(session.centerApiBase, token, selectedGuildId)
socket.on('presence.offline', (e: { onlineCount?: number }) => setOnlineCount(e.onlineCount ?? 0)) setMembers(list)
socket.on('typing.start', (e: { channelId?: string; userId?: string }) => { } catch {
if (!e.channelId || e.channelId !== channelId || !e.userId || e.userId === session?.user.id) return setError('Failed to load guild members')
setTypingUsers((prev) => (prev.includes(e.userId as string) ? prev : [...prev, e.userId as string])) setMembers([])
}) }
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('消息拉取失败') setError('Failed to load messages')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
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('')
} catch {
setError('发送失败')
}
}
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() await pullMessages()
} catch { } catch {
setError('编辑失败') setError('Failed to send message')
} }
} }
async function deleteMessage(messageId: string) { async function addGuild() {
if (!channelId || !messageId || !guild || !guildToken) return if (!session || !joinGuildNodeId.trim()) return
setError('') setError('')
try { try {
await guildApi().delete(`/channels/${channelId}/messages/${messageId}`) const token = await ensureFreshToken()
await pullMessages() if (!token) return
await joinGuildCenter(session.centerApiBase, token, joinGuildNodeId.trim())
await refreshGuilds()
setJoinGuildNodeId('')
} catch { } catch {
setError('删除失败') setError('Failed to add guild')
} }
} }
function onChangeChannel(value: string) { function toggleMember(userId: string) {
setChannelId(value) setSelectedMemberIds((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
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) { async function createChannel() {
const wasEmpty = !content.trim() if (!guild || !guildToken || !newChannelName.trim()) return
const nowEmpty = !value.trim() if (!guildDbId) {
setContent(value) setError('Cannot create channel: guildId is missing')
if (!channelId || !socket) return return
if (wasEmpty && !nowEmpty) socket.emit('typing.start', { channelId }) }
if (!wasEmpty && nowEmpty) socket.emit('typing.stop', { channelId }) setError('')
try {
const payload = {
name: newChannelName.trim(),
guildId: guildDbId,
memberUserIds: selectedMemberIds,
}
const res = await guildApi().post('/channels', payload)
const createdId = res.data?.id as string | undefined
setNewChannelName('')
setSelectedMemberIds([])
setShowCreateChannelModal(false)
await loadChannels()
if (createdId) setSelectedChannelId(createdId)
} catch {
setError('Failed to create channel')
}
} }
useEffect(() => { useEffect(() => {
if (!channelId || !socket || !socket.connected) return void loadMembers()
socket.emit('join_channel', { channelId }) setSelectedMemberIds([])
}, [selectedGuildId])
useEffect(() => {
void loadChannels()
setMessages([])
setSelectedChannelId('')
}, [selectedGuildId, guildDbId])
useEffect(() => {
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 () => { return () => {
socket.emit('leave_channel', { channelId }) socket.emit('leave_channel', { channelId: selectedChannelId })
socket.removeAllListeners()
socket.disconnect()
} }
}, [channelId, socket, socketState]) }, [socket, selectedChannelId])
return ( return (
<section> <section className="chat-layout">
<h2></h2> <div className={`chat-top-grid ${!showGuilds ? 'hide-v1' : ''} ${!showChannels ? 'hide-v2' : ''} ${!showMembers ? 'hide-v4' : ''}`}>
<p>Guild: {guildId || '-'}</p> <div className="panel col-list col-v1">
<p>Socket: {socketState}</p> <h3>Guilds</h3>
<p>线: {onlineCount}</p> <div className="row-wrap" style={{ marginBottom: 8 }}>
{loading ? <p>...</p> : null} <input className="input" value={joinGuildNodeId} onChange={(e) => setJoinGuildNodeId(e.target.value)} placeholder="Guild nodeId" />
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null} <button className="btn btn-secondary" onClick={addGuild}>Add guild</button>
{typingUsers.length ? <p>: {typingUsers.join(', ')}</p> : null}
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input value={channelId} onChange={(e) => onChangeChannel(e.target.value)} placeholder="Channel ID" />
<input value={seqFrom} onChange={(e) => setSeqFrom(e.target.value)} placeholder="seq_from" />
<input value={seqTo} onChange={(e) => setSeqTo(e.target.value)} placeholder="seq_to" />
<input value={limit} onChange={(e) => setLimit(e.target.value)} placeholder="limit" />
<button onClick={pullMessages}></button>
</div> </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> <ul className="list-reset">
<input value={content} onChange={(e) => onTypingChange(e.target.value)} placeholder="输入消息" /> {guilds.map((g) => (
<button onClick={sendMessage}></button> <li key={g.nodeId}>
</div> <button className={`list-btn ${selectedGuildId === g.nodeId ? 'active' : ''}`} onClick={() => setSelectedGuildId(g.nodeId)}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}> {g.name}
<input value={editingMessageId} onChange={(e) => setEditingMessageId(e.target.value)} placeholder="messageId" />
<input value={editingContent} onChange={(e) => setEditingContent(e.target.value)} placeholder="新内容" />
<button onClick={editMessage}></button>
</div>
<ul>
{messages.map((m) => (
<li key={m.messageId}>
#{m.seq} {m.content} ({m.messageId}){' '}
<button type="button" onClick={() => deleteMessage(m.messageId)}>
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
{!loading && !messages.length ? <p></p> : null} </div>
{pageInfo ? (
<p> <div className="panel col-list col-v2">
next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} | <h3>Channels</h3>
has_more: {String(pageInfo.hasMore ?? false)} <div className="row-wrap" style={{ marginBottom: 8 }}>
</p> <button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(true)}>Create channel</button>
</div>
<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 col-v3">
<div className="panel chat-history">
<h3>Messages</h3>
{loading ? <p className="muted">Loading...</p> : null}
{error ? <p className="error-text">{error}</p> : null}
<ul className="list-reset">
{messages.map((m) => (
<li key={m.messageId} className="card" style={{ marginTop: 8 }}>
<div>
<strong>#{m.seq}</strong> {m.content}
</div>
</li>
))}
</ul>
</div>
<div className="panel composer">
<input className="input" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Type a message" />
<button className="btn" onClick={sendMessage}>Send</button>
</div>
</div>
<div className="panel col-list col-v4">
<h3>Members</h3>
<ul className="list-reset">
{members.map((m) => (
<li key={m.userId}><span className="muted">{m.email}</span></li>
))}
</ul>
</div>
</div>
<div className="panel footer-actions">
<button className="btn btn-secondary" onClick={() => setShowGuilds((v) => !v)}>{showGuilds ? 'Hide v1' : 'Show v1'}</button>
<button className="btn btn-secondary" onClick={() => setShowChannels((v) => !v)}>{showChannels ? 'Hide v2' : 'Show v2'}</button>
<button className="btn btn-secondary" onClick={() => setShowMembers((v) => !v)}>{showMembers ? 'Hide v4' : 'Show v4'}</button>
<button className="btn btn-secondary" onClick={() => void logout()}>Logout</button>
<button className="btn btn-secondary" type="button">Settings</button>
</div>
{showCreateChannelModal ? (
<div className="modal-backdrop" onClick={() => setShowCreateChannelModal(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h3>Create channel</h3>
<input className="input" value={newChannelName} onChange={(e) => setNewChannelName(e.target.value)} placeholder="Channel name" />
<p className="muted" style={{ marginTop: 8 }}>Select members to include</p>
<div className="modal-list">
{members.map((m) => (
<label key={m.userId} className="row-wrap" style={{ alignItems: 'center' }}>
<input type="checkbox" checked={selectedMemberIds.includes(m.userId)} onChange={() => toggleMember(m.userId)} />
<span>{m.email}</span>
</label>
))}
</div>
<div className="row-wrap" style={{ marginTop: 12 }}>
<button className="btn" onClick={createChannel}>Create</button>
<button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(false)}>Cancel</button>
</div>
</div>
</div>
) : null} ) : null}
</section> </section>
) )

View File

@@ -7,7 +7,6 @@ export default function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { login, isAuthed, session } = useAuth() const { login, isAuthed, session } = useAuth()
const [centerApiBase, setCenterApiBase] = useState('http://localhost:7001/api') const [centerApiBase, setCenterApiBase] = useState('http://localhost:7001/api')
const [centerApiKey, setCenterApiKey] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -16,38 +15,35 @@ export default function LoginPage() {
e.preventDefault() e.preventDefault()
setError('') setError('')
try { try {
await login(centerApiBase.trim(), centerApiKey.trim(), email, password) await login(centerApiBase.trim(), email, password)
navigate('/workspace') navigate('/workspace')
} catch { } catch {
setError('登录失败,请检查账号密码') setError('Login failed. Please check your email and password.')
} }
} }
return ( return (
<section> <section className="panel">
<h2></h2> <h2>Login</h2>
{isAuthed ? <p>{session?.user.email}</p> : null} {isAuthed ? <p className="muted">Current user: {session?.user.email}</p> : null}
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 8, maxWidth: 420 }}> <form onSubmit={onSubmit} className="form-grid" style={{ maxWidth: 460 }}>
<input <input
className="input"
value={centerApiBase} value={centerApiBase}
onChange={(e) => setCenterApiBase(e.target.value)} onChange={(e) => setCenterApiBase(e.target.value)}
placeholder="Center API Base (e.g. http://localhost:7001/api)" placeholder="Center API Base (e.g. http://localhost:7001/api)"
/> />
<input className="input" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
<input <input
value={centerApiKey} className="input"
onChange={(e) => setCenterApiKey(e.target.value)}
placeholder="Center API Key"
/>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
<input
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Password" placeholder="Password"
type="password" type="password"
/> />
<button type="submit"></button> <button className="btn" type="submit">Sign in</button>
</form> </form>
<p>{error}</p> {error ? <p className="error-text">{error}</p> : null}
</section> </section>
) )
} }

View File

@@ -22,7 +22,7 @@ export default function WorkspacePage() {
const res = await axios.get(`${session.centerApiBase}/healthz`) const res = await axios.get(`${session.centerApiBase}/healthz`)
setHealth(JSON.stringify(res.data)) setHealth(JSON.stringify(res.data))
} catch { } catch {
setHealth('center healthz 访问失败') setHealth('Failed to access center healthz')
} }
} }
@@ -45,26 +45,29 @@ export default function WorkspacePage() {
} }
return ( return (
<section> <section className="panel">
<h2></h2> <h2>Workspace</h2>
<p>Center: {session?.centerApiBase || '-'}</p> <p className="muted">Center: {session?.centerApiBase || '-'}</p>
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 12 }}>
<button type="button" onClick={checkCenterHealth}> <button className="btn" type="button" onClick={checkCenterHealth}>
Center healthz Test Center healthz
</button> </button>
</div> </div>
<p>{health}</p> {health ? <p className="muted">{health}</p> : null}
<ul> <ul className="list-reset">
{(session?.guilds ?? []).map((g) => ( {(session?.guilds ?? []).map((g) => (
<li key={g.nodeId} style={{ marginTop: 8 }}> <li key={g.nodeId} className="card" style={{ marginTop: 12 }}>
<strong>{g.name}</strong> ({g.nodeId}) - {g.endpoint}{' '} <div>
<button type="button" onClick={() => loadChannels(g.nodeId, g.endpoint)}> <strong>{g.name}</strong> <span className="muted">({g.nodeId})</span>
</div>
<div className="muted">{g.endpoint}</div>
<button className="btn btn-secondary" type="button" onClick={() => loadChannels(g.nodeId, g.endpoint)}>
Load channels
</button> </button>
<ul> <ul className="list-reset" style={{ marginTop: 10 }}>
{(channelsByGuild[g.nodeId] ?? []).map((c) => ( {(channelsByGuild[g.nodeId] ?? []).map((c) => (
<li key={c.id}> <li key={c.id} style={{ marginTop: 6 }}>
<Link <Link
to={`/chat?guildId=${encodeURIComponent(g.nodeId)}&channelId=${encodeURIComponent(c.id)}`} to={`/chat?guildId=${encodeURIComponent(g.nodeId)}&channelId=${encodeURIComponent(c.id)}`}
> >