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:
@@ -1,29 +0,0 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { Invite, MyInvite, RoomMember } from '../types'
|
||||
|
||||
export function createInvite(roomId: string, targetUsername: string): Promise<Invite> {
|
||||
return apiFetch<Invite>(`/api/rooms/${roomId}/invites`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target_username: targetUsername }),
|
||||
})
|
||||
}
|
||||
|
||||
export function listRoomInvites(roomId: string): Promise<Invite[]> {
|
||||
return apiFetch<Invite[]>(`/api/rooms/${roomId}/invites`)
|
||||
}
|
||||
|
||||
export function revokeInvite(roomId: string, inviteId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/rooms/${roomId}/invites/${inviteId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function listMyInvites(): Promise<MyInvite[]> {
|
||||
return apiFetch<MyInvite[]>('/api/invites/mine')
|
||||
}
|
||||
|
||||
export function acceptInvite(inviteId: string): Promise<RoomMember> {
|
||||
return apiFetch<RoomMember>(`/api/invites/${inviteId}/accept`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function declineInvite(inviteId: string): Promise<Invite> {
|
||||
return apiFetch<Invite>(`/api/invites/${inviteId}/decline`, { method: 'POST' })
|
||||
}
|
||||
@@ -46,6 +46,13 @@ export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
|
||||
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
|
||||
}
|
||||
|
||||
export function addRoomMember(roomId: string, userId: string): Promise<RoomMember> {
|
||||
return apiFetch<RoomMember>(`/api/rooms/${roomId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function removeMember(roomId: string, userId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/rooms/${roomId}/members/${userId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { UserDirectoryEntry } from '../types'
|
||||
|
||||
export function getUserAvatarUrl(userId: string, avatarFilename?: string | null): string {
|
||||
return `/api/users/${userId}/avatar${avatarFilename ? `?v=${avatarFilename}` : ''}`
|
||||
}
|
||||
|
||||
export function listUserDirectory(): Promise<UserDirectoryEntry[]> {
|
||||
return apiFetch<UserDirectoryEntry[]>('/api/users')
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { acceptInvite, declineInvite, listMyInvites } from '../api/invites'
|
||||
import { ApiError } from '../api/client'
|
||||
import type { MyInvite } from '../types'
|
||||
import './Modal.css'
|
||||
|
||||
interface InvitesModalProps {
|
||||
onClose: () => void
|
||||
onAccepted: (roomId: string) => void
|
||||
onInvitesChanged: (count: number) => void
|
||||
}
|
||||
|
||||
export function InvitesModal({ onClose, onAccepted, onInvitesChanged }: InvitesModalProps) {
|
||||
const [invites, setInvites] = useState<MyInvite[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
const list = await listMyInvites()
|
||||
setInvites(list)
|
||||
onInvitesChanged(list.length)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : String(err)))
|
||||
.finally(() => setLoading(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
async function handleAccept(invite: MyInvite) {
|
||||
setBusyId(invite.id)
|
||||
setError(null)
|
||||
try {
|
||||
await acceptInvite(invite.id)
|
||||
await refresh()
|
||||
onAccepted(invite.room_id)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : String(err))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecline(invite: MyInvite) {
|
||||
setBusyId(invite.id)
|
||||
setError(null)
|
||||
try {
|
||||
await declineInvite(invite.id)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : String(err))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-scrim" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Your invites</h2>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="modal-error">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="modal-empty">Loading...</p>
|
||||
) : invites.length === 0 ? (
|
||||
<p className="modal-empty">No pending invites.</p>
|
||||
) : (
|
||||
invites.map((invite) => (
|
||||
<div key={invite.id} className="modal-list-row">
|
||||
<div className="modal-list-row-body">
|
||||
<div className="modal-list-row-title">{invite.room_name}</div>
|
||||
<div className="modal-list-row-sub">Invited by {invite.invited_by_username}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
disabled={busyId === invite.id}
|
||||
onClick={() => handleDecline(invite)}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={busyId === invite.id}
|
||||
onClick={() => handleAccept(invite)}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="modal-actions" style={{ marginTop: '1rem' }}>
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,33 @@
|
||||
.room-info-panel {
|
||||
width: 260px;
|
||||
position: relative;
|
||||
flex: none;
|
||||
min-width: 260px;
|
||||
max-width: 480px;
|
||||
border-left: 1px solid var(--ds-border);
|
||||
background: var(--ds-void-2);
|
||||
padding: var(--sp-4);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.room-info-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -3px;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.room-info-resize-handle:hover,
|
||||
.room-info-resize-handle:active {
|
||||
background: color-mix(in srgb, var(--ds-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.room-info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -81,26 +100,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.room-info-member-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.room-info-member-actions button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--ds-border);
|
||||
color: var(--ds-muted);
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
.room-info-role-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding-right: 20px;
|
||||
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
|
||||
linear-gradient(135deg, currentColor 50%, transparent 50%);
|
||||
background-position: calc(100% - 11px) 55%, calc(100% - 6px) 55%;
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.room-info-member-actions button:hover {
|
||||
color: var(--ds-text);
|
||||
border-color: var(--ds-accent);
|
||||
.room-info-role-select:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.room-info-role-select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
@@ -135,6 +155,7 @@
|
||||
.room-info-invite-form {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.room-info-invite-form input {
|
||||
@@ -147,18 +168,6 @@
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.room-info-pending {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.room-info-pending-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ds-text);
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.room-info-error {
|
||||
color: var(--ds-danger);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { ApiError } from '../api/client'
|
||||
import { createInvite, listRoomInvites, revokeInvite } from '../api/invites'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import { getUserAvatarUrl, listUserDirectory } from '../api/users'
|
||||
import {
|
||||
addRoomMember,
|
||||
changeMemberRole,
|
||||
deleteRoom,
|
||||
leaveRoom,
|
||||
@@ -19,17 +19,19 @@ import {
|
||||
revokeIncomingWebhook,
|
||||
} from '../api/webhooks'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useResizableWidth } from '../hooks/useResizableWidth'
|
||||
import type {
|
||||
EventSubscription,
|
||||
EventType,
|
||||
Invite,
|
||||
MyRoomItem,
|
||||
RoomMember,
|
||||
RoomRole,
|
||||
UserDirectoryEntry,
|
||||
WebhookIncoming,
|
||||
} from '../types'
|
||||
import { RoomAvatar } from './RoomAvatar'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
import { UserPicker } from './UserPicker'
|
||||
import './RoomInfoPanel.css'
|
||||
|
||||
const EVENT_TYPES: EventType[] = ['message.created', 'message.updated']
|
||||
@@ -55,10 +57,15 @@ export function RoomInfoPanel({
|
||||
}: RoomInfoPanelProps) {
|
||||
const { user } = useAuth()
|
||||
const myRole = room.role
|
||||
const { width, startResize } = useResizableWidth({
|
||||
storageKey: 'room-info-panel-width',
|
||||
defaultWidth: 260,
|
||||
min: 260,
|
||||
max: 480,
|
||||
})
|
||||
|
||||
const [inviteUsername, setInviteUsername] = useState('')
|
||||
const [inviteError, setInviteError] = useState<string | null>(null)
|
||||
const [pendingInvites, setPendingInvites] = useState<Invite[]>([])
|
||||
const [directoryUsers, setDirectoryUsers] = useState<UserDirectoryEntry[]>([])
|
||||
const [busyUserId, setBusyUserId] = useState<string | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [nameDraft, setNameDraft] = useState(room.name)
|
||||
@@ -80,35 +87,26 @@ export function RoomInfoPanel({
|
||||
setNameDraft(room.name)
|
||||
setDescDraft(room.description ?? '')
|
||||
if (canManage) {
|
||||
listRoomInvites(room.id).then(setPendingInvites).catch(() => setPendingInvites([]))
|
||||
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
|
||||
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
|
||||
listUserDirectory().then(setDirectoryUsers).catch(() => setDirectoryUsers([]))
|
||||
} else {
|
||||
setPendingInvites([])
|
||||
setIncomingWebhooks([])
|
||||
setEventSubscriptions([])
|
||||
setDirectoryUsers([])
|
||||
}
|
||||
}, [room.id, room.name, room.description, canManage])
|
||||
|
||||
async function handleInvite(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const username = inviteUsername.trim()
|
||||
if (!username) return
|
||||
async function handleAddMember(target: UserDirectoryEntry) {
|
||||
setInviteError(null)
|
||||
try {
|
||||
await createInvite(room.id, username)
|
||||
setInviteUsername('')
|
||||
setPendingInvites(await listRoomInvites(room.id))
|
||||
await addRoomMember(room.id, target.id)
|
||||
onMembersChanged()
|
||||
} catch (err) {
|
||||
setInviteError(err instanceof ApiError ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(inviteId: string) {
|
||||
await revokeInvite(room.id, inviteId)
|
||||
setPendingInvites(await listRoomInvites(room.id))
|
||||
}
|
||||
|
||||
async function handleCreateWebhook(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setIntegrationsError(null)
|
||||
@@ -185,6 +183,29 @@ export function RoomInfoPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function memberActions(m: RoomMember): { value: string; label: string }[] {
|
||||
if (m.user_id === user?.id) return []
|
||||
if (myRole === 'owner') {
|
||||
const actions: { value: string; label: string }[] = []
|
||||
if (m.role === 'member') actions.push({ value: 'promote', label: 'Promote to admin' })
|
||||
if (m.role === 'admin') actions.push({ value: 'demote', label: 'Demote to member' })
|
||||
actions.push({ value: 'transfer', label: 'Make owner' })
|
||||
actions.push({ value: 'remove', label: 'Remove from room' })
|
||||
return actions
|
||||
}
|
||||
if (myRole === 'admin' && m.role === 'member') {
|
||||
return [{ value: 'remove', label: 'Remove from room' }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function handleMemberAction(userId: string, action: string) {
|
||||
if (action === 'promote') handleRoleChange(userId, 'admin')
|
||||
else if (action === 'demote') handleRoleChange(userId, 'member')
|
||||
else if (action === 'transfer') handleTransfer(userId)
|
||||
else if (action === 'remove') handleRemove(userId)
|
||||
}
|
||||
|
||||
async function handleLeave() {
|
||||
if (myRole === 'owner') return
|
||||
if (!confirm(`Leave #${room.name}?`)) return
|
||||
@@ -210,7 +231,8 @@ export function RoomInfoPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="room-info-panel">
|
||||
<aside className="room-info-panel" style={{ width }}>
|
||||
<div className="room-info-resize-handle" onPointerDown={startResize} />
|
||||
<div className="room-info-header">
|
||||
<span className="room-info-header-label">Details</span>
|
||||
<button type="button" className="room-info-close" onClick={onClose} aria-label="Close">
|
||||
@@ -229,107 +251,53 @@ export function RoomInfoPanel({
|
||||
|
||||
<div className="room-info-section">
|
||||
<div className="room-info-label">Members</div>
|
||||
{members.map((m, i) => (
|
||||
<div key={m.user_id} className="room-info-member-row">
|
||||
<UserAvatar
|
||||
username={m.username}
|
||||
colorIndex={i}
|
||||
size={24}
|
||||
avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null}
|
||||
/>
|
||||
<span className="room-info-member-name">{m.display_name || m.username}</span>
|
||||
<span className={`role-badge role-badge-${m.role}`}>{m.role}</span>
|
||||
{myRole === 'owner' && m.user_id !== user?.id && (
|
||||
<div className="room-info-member-actions">
|
||||
{m.role === 'member' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyUserId === m.user_id}
|
||||
onClick={() => handleRoleChange(m.user_id, 'admin')}
|
||||
title="Promote to admin"
|
||||
>
|
||||
Promote
|
||||
</button>
|
||||
)}
|
||||
{m.role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyUserId === m.user_id}
|
||||
onClick={() => handleRoleChange(m.user_id, 'member')}
|
||||
title="Demote to member"
|
||||
>
|
||||
Demote
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
{members.map((m, i) => {
|
||||
const actions = memberActions(m)
|
||||
return (
|
||||
<div key={m.user_id} className="room-info-member-row">
|
||||
<UserAvatar
|
||||
username={m.username}
|
||||
colorIndex={i}
|
||||
size={24}
|
||||
avatarUrl={m.avatar_filename ? getUserAvatarUrl(m.user_id, m.avatar_filename) : null}
|
||||
/>
|
||||
<span className="room-info-member-name">{m.display_name || m.username}</span>
|
||||
{actions.length > 0 ? (
|
||||
<select
|
||||
className={`role-badge role-badge-${m.role} room-info-role-select`}
|
||||
value={m.role}
|
||||
disabled={busyUserId === m.user_id}
|
||||
onClick={() => handleTransfer(m.user_id)}
|
||||
title="Transfer ownership"
|
||||
onChange={(e) => {
|
||||
const action = e.target.value
|
||||
if (action && action !== m.role) handleMemberAction(m.user_id, action)
|
||||
}}
|
||||
aria-label={`Role and actions for ${m.display_name || m.username}`}
|
||||
>
|
||||
Make owner
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="room-info-danger-link"
|
||||
disabled={busyUserId === m.user_id}
|
||||
onClick={() => handleRemove(m.user_id)}
|
||||
title="Remove from room"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{myRole === 'admin' && m.role === 'member' && m.user_id !== user?.id && (
|
||||
<div className="room-info-member-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="room-info-danger-link"
|
||||
disabled={busyUserId === m.user_id}
|
||||
onClick={() => handleRemove(m.user_id)}
|
||||
title="Remove from room"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<option value={m.role}>{m.role[0].toUpperCase() + m.role.slice(1)}</option>
|
||||
{actions.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className={`role-badge role-badge-${m.role}`}>{m.role}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<div className="room-info-section">
|
||||
<div className="room-info-label">Invite someone</div>
|
||||
<form className="room-info-invite-form" onSubmit={handleInvite}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={inviteUsername}
|
||||
onChange={(e) => setInviteUsername(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn-secondary">
|
||||
Invite
|
||||
</button>
|
||||
</form>
|
||||
<div className="room-info-label">Add someone</div>
|
||||
<UserPicker
|
||||
users={directoryUsers}
|
||||
excludeUserIds={members.map((m) => m.user_id)}
|
||||
placeholder="Search users to add…"
|
||||
onSelect={handleAddMember}
|
||||
/>
|
||||
{inviteError && <p className="room-info-error">{inviteError}</p>}
|
||||
|
||||
{pendingInvites.length > 0 && (
|
||||
<div className="room-info-pending">
|
||||
{pendingInvites.map((inv) => (
|
||||
<div key={inv.id} className="room-info-pending-row">
|
||||
<span>{inv.target_username ?? 'Unknown user'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="room-info-danger-link"
|
||||
onClick={() => handleRevoke(inv.id)}
|
||||
title="Revoke invite"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -80,21 +80,6 @@
|
||||
background: color-mix(in srgb, var(--ds-text) 5%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-badge {
|
||||
margin-left: auto;
|
||||
background: var(--ds-accent);
|
||||
color: var(--ds-void);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
border-radius: var(--radius-pill);
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
padding: 10px 16px 6px;
|
||||
font-size: 0.72rem;
|
||||
|
||||
@@ -9,8 +9,6 @@ interface SidebarProps {
|
||||
onSearchChange: (value: string) => void
|
||||
onOpenNewRoom: () => void
|
||||
onOpenBrowse: () => void
|
||||
onOpenInvites: () => void
|
||||
inviteCount: number
|
||||
unavailableOffline?: boolean
|
||||
}
|
||||
|
||||
@@ -21,8 +19,6 @@ export function Sidebar({
|
||||
onSearchChange,
|
||||
onOpenNewRoom,
|
||||
onOpenBrowse,
|
||||
onOpenInvites,
|
||||
inviteCount,
|
||||
unavailableOffline,
|
||||
}: SidebarProps) {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
@@ -52,15 +48,6 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
<div className="sidebar-scroll">
|
||||
<button type="button" className="sidebar-entry" onClick={onOpenInvites}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
|
||||
<path d="M22 6 12 13 2 6" />
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
</svg>
|
||||
Invites
|
||||
{inviteCount > 0 && <span className="sidebar-badge">{inviteCount}</span>}
|
||||
</button>
|
||||
|
||||
<button type="button" className="sidebar-entry" onClick={onOpenBrowse}>
|
||||
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="6" />
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
.user-picker {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-picker input {
|
||||
width: 100%;
|
||||
background: var(--ds-surface-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 10px;
|
||||
color: var(--ds-text);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.user-picker-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--ds-surface-2);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
z-index: 20;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.user-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ds-text);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-picker-row-active,
|
||||
.user-picker-row:hover {
|
||||
background: color-mix(in srgb, var(--ds-accent) 16%, transparent);
|
||||
}
|
||||
|
||||
.user-picker-row-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-picker-row-username {
|
||||
color: var(--ds-muted);
|
||||
font-size: 0.74rem;
|
||||
margin-left: auto;
|
||||
padding-left: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.user-picker-empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ds-muted);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getUserAvatarUrl } from '../api/users'
|
||||
import type { UserDirectoryEntry } from '../types'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
import './UserPicker.css'
|
||||
|
||||
interface UserPickerProps {
|
||||
users: UserDirectoryEntry[]
|
||||
excludeUserIds?: string[]
|
||||
placeholder?: string
|
||||
onSelect: (user: UserDirectoryEntry) => void
|
||||
}
|
||||
|
||||
export function UserPicker({ users, excludeUserIds, placeholder = 'Search users…', onSelect }: UserPickerProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [highlighted, setHighlighted] = useState(0)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const excluded = new Set(excludeUserIds ?? [])
|
||||
const q = query.trim().toLowerCase()
|
||||
const matches = q
|
||||
? users
|
||||
.filter((u) => !excluded.has(u.id))
|
||||
.filter((u) => u.username.toLowerCase().includes(q) || (u.display_name ?? '').toLowerCase().includes(q))
|
||||
.slice(0, 8)
|
||||
: []
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
function choose(u: UserDirectoryEntry) {
|
||||
onSelect(u)
|
||||
setQuery('')
|
||||
setOpen(false)
|
||||
setHighlighted(0)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (!open || matches.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setHighlighted((h) => Math.min(h + 1, matches.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setHighlighted((h) => Math.max(h - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
choose(matches[highlighted])
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-picker" ref={rootRef}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
setHighlighted(0)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{open && matches.length > 0 && (
|
||||
<div className="user-picker-dropdown">
|
||||
{matches.map((u, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={u.id}
|
||||
className={`user-picker-row${i === highlighted ? ' user-picker-row-active' : ''}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(u)}
|
||||
onMouseEnter={() => setHighlighted(i)}
|
||||
>
|
||||
<UserAvatar
|
||||
username={u.username}
|
||||
colorIndex={i}
|
||||
size={22}
|
||||
avatarUrl={u.avatar_filename ? getUserAvatarUrl(u.id, u.avatar_filename) : null}
|
||||
/>
|
||||
<span className="user-picker-row-name">{u.display_name || u.username}</span>
|
||||
{u.display_name && <span className="user-picker-row-username">@{u.username}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open && q && matches.length === 0 && <div className="user-picker-dropdown user-picker-empty">No matches</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useRef, useState, type PointerEvent } from 'react'
|
||||
|
||||
interface UseResizableWidthOptions {
|
||||
storageKey: string
|
||||
defaultWidth: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
// Right-anchored resizable panel: width is the distance from the cursor to
|
||||
// the viewport's right edge, so a handle on the panel's left edge drags
|
||||
// naturally. Persists to localStorage so it survives a reload.
|
||||
export function useResizableWidth({ storageKey, defaultWidth, min, max }: UseResizableWidthOptions) {
|
||||
const [width, setWidth] = useState(() => {
|
||||
const stored = Number(localStorage.getItem(storageKey))
|
||||
return stored >= min && stored <= max ? stored : defaultWidth
|
||||
})
|
||||
const widthRef = useRef(width)
|
||||
widthRef.current = width
|
||||
const draggingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
function handleMove(e: PointerEvent<Window> | globalThis.PointerEvent) {
|
||||
if (!draggingRef.current) return
|
||||
const next = Math.min(max, Math.max(min, window.innerWidth - e.clientX))
|
||||
setWidth(next)
|
||||
}
|
||||
function handleUp() {
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
localStorage.setItem(storageKey, String(widthRef.current))
|
||||
}
|
||||
window.addEventListener('pointermove', handleMove as (e: globalThis.PointerEvent) => void)
|
||||
window.addEventListener('pointerup', handleUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove as (e: globalThis.PointerEvent) => void)
|
||||
window.removeEventListener('pointerup', handleUp)
|
||||
}
|
||||
}, [max, min, storageKey])
|
||||
|
||||
const startResize = useCallback((e: PointerEvent) => {
|
||||
e.preventDefault()
|
||||
draggingRef.current = true
|
||||
}, [])
|
||||
|
||||
return { width, startResize }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+7
-16
@@ -9,6 +9,13 @@ export interface User {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UserDirectoryEntry {
|
||||
id: string
|
||||
username: string
|
||||
display_name: string | null
|
||||
avatar_filename: string | null
|
||||
}
|
||||
|
||||
export type RoomRole = 'owner' | 'admin' | 'member'
|
||||
|
||||
export interface Room {
|
||||
@@ -39,22 +46,6 @@ export interface RoomMember {
|
||||
|
||||
export type InviteStatus = 'pending' | 'accepted' | 'revoked'
|
||||
|
||||
export interface Invite {
|
||||
id: string
|
||||
room_id: string
|
||||
invited_by: string
|
||||
target_user_id: string | null
|
||||
target_username: string | null
|
||||
status: InviteStatus
|
||||
expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface MyInvite extends Invite {
|
||||
room_name: string
|
||||
invited_by_username: string
|
||||
}
|
||||
|
||||
export interface ReactionSummary {
|
||||
emoji: string
|
||||
count: number
|
||||
|
||||
Reference in New Issue
Block a user