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 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={
<ProtectedRoute>
<WorkspacePage />
<ChatPage />
</ProtectedRoute>
}
/>

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import type { PropsWithChildren } from 'react'
import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } 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 type { AuthContextValue } from './auth-context'
@@ -13,15 +13,15 @@ export function AuthProvider({ children }: PropsWithChildren) {
() => ({
session,
isAuthed: !!session,
login: async (centerApiBase: string, centerApiKey: string, email: string, password: string) => {
const next = await loginCenter(centerApiBase, centerApiKey, { email, password })
login: async (centerApiBase: string, email: string, password: string) => {
const next = await loginCenter(centerApiBase, { email, password })
setAuthSession(next)
setSession(next)
},
logout: async () => {
if (session?.refreshToken) {
try {
await logoutCenter(session.centerApiBase, session.centerApiKey, session.refreshToken)
await logoutCenter(session.centerApiBase, session.refreshToken)
} catch {
// noop
}
@@ -33,17 +33,45 @@ export function AuthProvider({ children }: PropsWithChildren) {
if (!session) return null
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 = {
...session,
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
tokenType: refreshed.tokenType,
expiresIn: refreshed.expiresIn,
}
setAuthSession(next)
setSession(next)
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],
)

View File

@@ -4,9 +4,10 @@ import type { AuthSession } from '../lib/auth-storage'
export type AuthContextValue = {
session: AuthSession | null
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>
ensureFreshToken: () => Promise<string | null>
refreshGuilds: () => Promise<void>
}
export const AuthContext = createContext<AuthContextValue | null>(null)

View File

@@ -64,6 +64,209 @@
body {
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,

View File

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

View File

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

View File

@@ -4,22 +4,28 @@ import type { AuthSession } from './auth-storage'
export type LoginPayload = { email: string; password: string }
type LoginResponse = {
centerApiBase: string
accessToken: string
refreshToken: string
tokenType: string
expiresIn?: number
user: { id: string; email: string }
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 = {
accessToken: string
refreshToken: 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({
baseURL: centerApiBase,
timeout: 10000,
@@ -27,7 +33,6 @@ function centerClient(centerApiBase: string, centerApiKey: string) {
client.interceptors.request.use((request) => {
const requestId = crypto.randomUUID()
request.headers['x-api-key'] = centerApiKey
request.headers['x-request-id'] = requestId
request.headers['x-client-name'] = 'fabric-frontend'
return request
@@ -36,16 +41,43 @@ function centerClient(centerApiBase: string, centerApiKey: string) {
return client
}
export async function loginCenter(centerApiBase: string, centerApiKey: string, payload: LoginPayload): Promise<AuthSession> {
const res = await centerClient(centerApiBase, centerApiKey).post<LoginResponse>('/auth/login', payload)
return { ...res.data, centerApiBase, centerApiKey }
export async function loginCenter(centerApiBase: string, payload: LoginPayload): Promise<AuthSession> {
const res = await centerClient(centerApiBase).post<LoginResponse>('/auth/login', payload)
return { ...res.data, centerApiBase }
}
export async function refreshCenter(centerApiBase: string, centerApiKey: string, refreshToken: string): Promise<RefreshResponse> {
const res = await centerClient(centerApiBase, centerApiKey).post<RefreshResponse>('/auth/refresh', { refreshToken })
export async function refreshCenter(centerApiBase: string, refreshToken: string): Promise<RefreshResponse> {
const res = await centerClient(centerApiBase).post<RefreshResponse>('/auth/refresh', { refreshToken })
return res.data
}
export async function logoutCenter(centerApiBase: string, centerApiKey: string, refreshToken: string): Promise<void> {
await centerClient(centerApiBase, centerApiKey).post('/auth/logout', { refreshToken })
export async function logoutCenter(centerApiBase: string, refreshToken: string): Promise<void> {
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 { 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'
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
type MessageItem = {
messageId: string
@@ -11,35 +11,38 @@ type MessageItem = {
isDeleted?: boolean
}
type PageInfo = {
nextExpectedSeq?: number
highestCommittedSeq?: number
hasMore?: boolean
}
type GuildChannel = { id: string; name: string; guildId?: string }
type MemberItem = { userId: string; email: string; status: 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, ensureFreshToken, refreshGuilds } = useAuth()
const [selectedGuildId, setSelectedGuildId] = useState('')
const [selectedChannelId, setSelectedChannelId] = useState('')
const [channels, setChannels] = useState<GuildChannel[]>([])
const [guildDbId, setGuildDbId] = useState('')
const [members, setMembers] = useState<MemberItem[]>([])
const [messages, setMessages] = useState<MessageItem[]>([])
const [pageInfo, setPageInfo] = useState<PageInfo | null>(null)
const [socketState, setSocketState] = useState<'offline' | 'online'>('offline')
const [onlineCount, setOnlineCount] = useState(0)
const [typingUsers, setTypingUsers] = useState<string[]>([])
const [content, setContent] = useState('')
const [newChannelName, setNewChannelName] = useState('')
const [joinGuildNodeId, setJoinGuildNodeId] = useState('')
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 [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 +50,7 @@ export default function ChatPage() {
return axios.create({
baseURL: `${guild.endpoint}/api`,
timeout: 10000,
headers: {
Authorization: `Bearer ${guildToken}`,
},
headers: { Authorization: `Bearer ${guildToken}` },
})
}
@@ -57,190 +58,245 @@ 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', { params: guildDbId ? { guildId: guildDbId } : undefined })
const list = Array.isArray(res.data) ? (res.data as GuildChannel[]) : []
setChannels(list)
if (!guildDbId && list[0]?.guildId) setGuildDbId(list[0].guildId)
if (!selectedChannelId && list.length) setSelectedChannelId(list[0].id)
} catch {
setError('Failed to load channels')
setChannels([])
}
}
async function loadMembers() {
if (!session || !selectedGuildId) return
setError('')
try {
const token = await ensureFreshToken()
if (!token) return
const list = await guildMembersCenter(session.centerApiBase, token, selectedGuildId)
setMembers(list)
} catch {
setError('Failed to load guild members')
setMembers([])
}
}
}, [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('消息拉取失败')
setError('Failed to load messages')
} finally {
setLoading(false)
}
}
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('')
} 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()
} catch {
setError('编辑失败')
setError('Failed to send message')
}
}
async function deleteMessage(messageId: string) {
if (!channelId || !messageId || !guild || !guildToken) return
async function addGuild() {
if (!session || !joinGuildNodeId.trim()) return
setError('')
try {
await guildApi().delete(`/channels/${channelId}/messages/${messageId}`)
await pullMessages()
const token = await ensureFreshToken()
if (!token) return
await joinGuildCenter(session.centerApiBase, token, joinGuildNodeId.trim())
await refreshGuilds()
setJoinGuildNodeId('')
} catch {
setError('删除失败')
setError('Failed to add guild')
}
}
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 toggleMember(userId: string) {
setSelectedMemberIds((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
}
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 })
async function createChannel() {
if (!guild || !guildToken || !newChannelName.trim()) return
if (!guildDbId) {
setError('Cannot create channel: guildId is missing')
return
}
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(() => {
if (!channelId || !socket || !socket.connected) return
socket.emit('join_channel', { channelId })
void loadMembers()
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 () => {
socket.emit('leave_channel', { channelId })
socket.emit('leave_channel', { channelId: selectedChannelId })
socket.removeAllListeners()
socket.disconnect()
}
}, [channelId, socket, socketState])
}, [socket, selectedChannelId])
return (
<section>
<h2></h2>
<p>Guild: {guildId || '-'}</p>
<p>Socket: {socketState}</p>
<p>线: {onlineCount}</p>
{loading ? <p>...</p> : null}
{error ? <p style={{ color: 'crimson' }}>{error}</p> : null}
{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>
<section className="chat-layout">
<div className={`chat-top-grid ${!showGuilds ? 'hide-v1' : ''} ${!showChannels ? 'hide-v2' : ''} ${!showMembers ? 'hide-v4' : ''}`}>
<div className="panel col-list col-v1">
<h3>Guilds</h3>
<div className="row-wrap" style={{ marginBottom: 8 }}>
<input className="input" value={joinGuildNodeId} onChange={(e) => setJoinGuildNodeId(e.target.value)} placeholder="Guild nodeId" />
<button className="btn btn-secondary" onClick={addGuild}>Add guild</button>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input value={content} onChange={(e) => onTypingChange(e.target.value)} placeholder="输入消息" />
<button onClick={sendMessage}></button>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<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)}>
<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>
{!loading && !messages.length ? <p></p> : null}
{pageInfo ? (
<p>
next_expected_seq: {pageInfo.nextExpectedSeq ?? '-'} | highest_committed_seq: {pageInfo.highestCommittedSeq ?? '-'} |
has_more: {String(pageInfo.hasMore ?? false)}
</p>
</div>
<div className="panel col-list col-v2">
<h3>Channels</h3>
<div className="row-wrap" style={{ marginBottom: 8 }}>
<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}
</section>
)

View File

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

View File

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