Private
Public Access
Add resizable room panel, searchable user picker, and direct room membership
Room info panel is now user-resizable (fixing a layout clip at narrow widths), and every user-selection spot (room membership, admin ownership transfer) uses a new searchable UserPicker instead of raw text input or prompt(). Member rows fold role + actions into a single inline dropdown instead of a row of buttons, so the member list stays usable as rooms grow. Room invites (the accept/decline flow) are replaced by adding a user to a room directly -- an admin/owner picks someone and they're a member immediately, with a "you've been added" notification email instead of an invite email. Drops the now-unused room_invites table.
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
import { ApiError } from '../api/client'
|
||||
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { UserPicker } from '../components/UserPicker'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { hashIndex } from '../lib/avatar'
|
||||
import type {
|
||||
@@ -52,6 +53,7 @@ export function AdminPage() {
|
||||
const [tab, setTab] = useState<Tab>('users')
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [rooms, setRooms] = useState<AdminRoom[]>([])
|
||||
const [transferringRoomId, setTransferringRoomId] = useState<string | null>(null)
|
||||
const [auditLog, setAuditLog] = useState<AuditLogEntry[]>([])
|
||||
const [auditHasMore, setAuditHasMore] = useState(true)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
@@ -191,18 +193,16 @@ export function AdminPage() {
|
||||
})
|
||||
}
|
||||
|
||||
async function handleTransferOwnership(r: AdminRoom) {
|
||||
const username = prompt(`Transfer #${r.name} to which username? (must already be a member)`)
|
||||
if (!username) return
|
||||
const target = users.find((u) => u.username === username.trim())
|
||||
if (!target) {
|
||||
setError(`No known user named "${username}"`)
|
||||
return
|
||||
}
|
||||
function toggleTransfer(roomId: string) {
|
||||
setTransferringRoomId((prev) => (prev === roomId ? null : roomId))
|
||||
}
|
||||
|
||||
async function handleTransferOwnership(r: AdminRoom, targetId: string) {
|
||||
await withBusy(r.id, async () => {
|
||||
const updated = await transferOwnershipAdmin(r.id, target.id)
|
||||
const updated = await transferOwnershipAdmin(r.id, targetId)
|
||||
setRooms((prev) => prev.map((x) => (x.id === updated.id ? updated : x)))
|
||||
})
|
||||
setTransferringRoomId(null)
|
||||
}
|
||||
|
||||
async function handleCreateBot() {
|
||||
@@ -465,24 +465,38 @@ export function AdminPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>#{r.name}</td>
|
||||
<td>{r.is_private ? 'Private' : 'Open'}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${r.is_archived ? 'inactive' : 'active'}`}>
|
||||
{r.is_archived ? 'Archived' : 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{r.member_count}</td>
|
||||
<td className="admin-actions">
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
|
||||
{r.is_archived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => handleTransferOwnership(r)}>
|
||||
Transfer ownership
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<Fragment key={r.id}>
|
||||
<tr>
|
||||
<td>#{r.name}</td>
|
||||
<td>{r.is_private ? 'Private' : 'Open'}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${r.is_archived ? 'inactive' : 'active'}`}>
|
||||
{r.is_archived ? 'Archived' : 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{r.member_count}</td>
|
||||
<td className="admin-actions">
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => handleToggleArchive(r)}>
|
||||
{r.is_archived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
<button type="button" disabled={busyId === r.id} onClick={() => toggleTransfer(r.id)}>
|
||||
{transferringRoomId === r.id ? 'Cancel' : 'Transfer ownership'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{transferringRoomId === r.id && (
|
||||
<tr>
|
||||
<td colSpan={5} className="admin-bot-detail">
|
||||
<UserPicker
|
||||
users={users}
|
||||
excludeUserIds={[r.owner_id]}
|
||||
placeholder="Search users to transfer ownership to…"
|
||||
onSelect={(target) => handleTransferOwnership(r, target.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { NetworkError } from '../api/client'
|
||||
import { listMyInvites } from '../api/invites'
|
||||
import { listMyRooms, listRoomMembers } from '../api/rooms'
|
||||
import { BrowseRoomsModal } from '../components/BrowseRoomsModal'
|
||||
import { ChatPane } from '../components/ChatPane'
|
||||
import { InvitesModal } from '../components/InvitesModal'
|
||||
import { NewRoomModal } from '../components/NewRoomModal'
|
||||
import { OfflineBanner } from '../components/OfflineBanner'
|
||||
import { RoomInfoPanel } from '../components/RoomInfoPanel'
|
||||
@@ -16,7 +14,7 @@ import { MOBILE_BREAKPOINT, useWindowWidth } from '../hooks/useWindowWidth'
|
||||
import type { MyRoomItem, RoomMember } from '../types'
|
||||
import './ChatShellPage.css'
|
||||
|
||||
type ModalKind = 'new' | 'browse' | 'invites' | null
|
||||
type ModalKind = 'new' | 'browse' | null
|
||||
|
||||
export function ChatShellPage() {
|
||||
const { roomId } = useParams<{ roomId?: string }>()
|
||||
@@ -28,7 +26,6 @@ export function ChatShellPage() {
|
||||
const [rooms, setRooms] = useState<MyRoomItem[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [members, setMembers] = useState<RoomMember[]>([])
|
||||
const [inviteCount, setInviteCount] = useState(0)
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
const [modal, setModal] = useState<ModalKind>(null)
|
||||
const [roomsUnavailableOffline, setRoomsUnavailableOffline] = useState(false)
|
||||
@@ -59,12 +56,6 @@ export function ChatShellPage() {
|
||||
refreshRooms().catch(() => {})
|
||||
}, [refreshRooms])
|
||||
|
||||
useEffect(() => {
|
||||
listMyInvites()
|
||||
.then((list) => setInviteCount(list.length))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshMembers()
|
||||
// Also re-run when the logged-in user's own profile changes (display
|
||||
@@ -92,8 +83,6 @@ export function ChatShellPage() {
|
||||
onSearchChange={setSearch}
|
||||
onOpenNewRoom={() => setModal('new')}
|
||||
onOpenBrowse={() => setModal('browse')}
|
||||
onOpenInvites={() => setModal('invites')}
|
||||
inviteCount={inviteCount}
|
||||
unavailableOffline={roomsUnavailableOffline && rooms.length === 0}
|
||||
/>
|
||||
)}
|
||||
@@ -156,16 +145,6 @@ export function ChatShellPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'invites' && (
|
||||
<InvitesModal
|
||||
onClose={() => setModal(null)}
|
||||
onInvitesChanged={setInviteCount}
|
||||
onAccepted={(id) => {
|
||||
setModal(null)
|
||||
refreshRooms().then(() => goToRoom(id))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user