feat(phase2): bulk delete + audit log; update roadmap/parity docs
This commit is contained in:
@@ -114,9 +114,18 @@ export function ChatArea() {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
|
||||
const clearSelectedMessages = useMessageStore((s) => s.clearSelectedMessages);
|
||||
const bulkDeleteMessages = useMessageStore((s) => s.bulkDeleteMessages);
|
||||
const selectedIds = useMessageStore((s) =>
|
||||
activeChannelId ? s.selectedMessageIds[activeChannelId] || new Set<string>() : new Set<string>(),
|
||||
);
|
||||
const canBulkDelete = true; // ponytail: permission enforced server-side; server-side is the source of truth
|
||||
|
||||
const channels = activeServerId
|
||||
? channelsByServer[activeServerId] || []
|
||||
: [];
|
||||
@@ -241,13 +250,22 @@ export function ChatArea() {
|
||||
: "[NO CHANNEL SELECTED]"}
|
||||
</span>
|
||||
{activeChannelId && (
|
||||
<button
|
||||
onClick={() => setShowSearch((prev) => !prev)}
|
||||
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
|
||||
title="Search messages"
|
||||
>
|
||||
[SEARCH]
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSelectMode((prev) => !prev)}
|
||||
className={`text-xs font-mono ${selectMode ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||
title="Toggle bulk delete selection"
|
||||
>
|
||||
[SELECT]
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSearch((prev) => !prev)}
|
||||
className="text-xs text-gb-fg-f hover:text-gb-orange font-mono"
|
||||
title="Search messages"
|
||||
>
|
||||
[SEARCH]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showSearch && activeChannelId && activeChannel && (
|
||||
@@ -257,13 +275,50 @@ export function ChatArea() {
|
||||
onClose={() => setShowSearch(false)}
|
||||
/>
|
||||
)}
|
||||
{selectMode && activeChannelId && (
|
||||
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
|
||||
<span>{selectedIds.size} selected</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => clearSelectedMessages(activeChannelId)}
|
||||
className="text-gb-fg-f hover:text-gb-orange"
|
||||
>
|
||||
[CLEAR]
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!activeChannelId || selectedIds.size === 0) return;
|
||||
if (!confirm(`Delete ${selectedIds.size} messages?`)) return;
|
||||
try {
|
||||
await bulkDeleteMessages(activeChannelId, Array.from(selectedIds));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Bulk delete failed');
|
||||
}
|
||||
}}
|
||||
disabled={selectedIds.size === 0 || !canBulkDelete}
|
||||
className="text-gb-red hover:text-gb-orange disabled:opacity-50"
|
||||
>
|
||||
[DELETE]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
||||
{!isLoading && messages.length === 0 && (
|
||||
<p className="text-gb-fg-f">[no messages in this channel]</p>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="break-words">
|
||||
<div
|
||||
key={message.id}
|
||||
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
|
||||
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
|
||||
>
|
||||
{selectMode && activeChannelId && (
|
||||
<span className="text-gb-fg-f mr-2">
|
||||
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gb-fg-f">
|
||||
[{formatTime(message.created_at)}]
|
||||
</span>{" "}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ChannelList } from './ChannelList.tsx';
|
||||
import { ConversationList } from './ConversationList.tsx';
|
||||
import { MemberList } from './MemberList.tsx';
|
||||
import { VoicePanel } from './VoicePanel.tsx';
|
||||
import { ServerSettingsModal } from './ServerSettingsModal.tsx';
|
||||
const STATUS_CYCLE: UserStatus[] = ['online', 'idle', 'dnd', 'offline'];
|
||||
function statusColor(status: UserStatus): string {
|
||||
switch (status) {
|
||||
@@ -38,6 +39,8 @@ export function Layout() {
|
||||
const location = useLocation();
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
const [dmMode, setDmMode] = useState(false);
|
||||
const [showServerSettings, setShowServerSettings] = useState(false);
|
||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||
useEffect(() => {
|
||||
fetchMe();
|
||||
wsConnect();
|
||||
@@ -101,6 +104,14 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link>
|
||||
{activeServerId && (
|
||||
<button
|
||||
onClick={() => setShowServerSettings(true)}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[SERVER SETTINGS]
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
|
||||
[LOGOUT]
|
||||
</button>
|
||||
@@ -146,6 +157,9 @@ export function Layout() {
|
||||
</div>
|
||||
{!dmMode && <MemberList />}
|
||||
</div>
|
||||
{showServerSettings && activeServerId && (
|
||||
<ServerSettingsModal serverId={activeServerId} onClose={() => setShowServerSettings(false)} />
|
||||
)}
|
||||
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
|
||||
<span>TERM v1.0</span>
|
||||
<span>{new Date().toISOString().slice(0, 10)}</span>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../lib/api.ts';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
|
||||
interface ServerSettingsModalProps {
|
||||
serverId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface AuditEntry {
|
||||
id: string;
|
||||
server_id: string;
|
||||
user_id: string | null;
|
||||
username: string | null;
|
||||
action_type: string;
|
||||
target_type: string | null;
|
||||
target_id: string | null;
|
||||
reason: string | null;
|
||||
changes: unknown;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
KICK: 'kicked member',
|
||||
BAN: 'banned member',
|
||||
UNBAN: 'unbanned member',
|
||||
MUTE: 'muted member',
|
||||
UNMUTE: 'unmuted member',
|
||||
ROLE_CREATE: 'created role',
|
||||
ROLE_UPDATE: 'updated role',
|
||||
ROLE_DELETE: 'deleted role',
|
||||
MEMBER_ROLES_UPDATE: 'updated member roles',
|
||||
CHANNEL_CREATE: 'created channel',
|
||||
CHANNEL_UPDATE: 'updated channel',
|
||||
CHANNEL_DELETE: 'deleted channel',
|
||||
SERVER_UPDATE: 'updated server',
|
||||
MESSAGE_DELETE: 'deleted message',
|
||||
BULK_DELETE: 'bulk deleted messages',
|
||||
};
|
||||
|
||||
export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) {
|
||||
const [tab, setTab] = useState<'audit'>('audit');
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const server = servers.find((s) => s.id === serverId);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'audit') return;
|
||||
setLoading(true);
|
||||
api.get<AuditEntry[]>(`/servers/${serverId}/audit-log`)
|
||||
.then((data) => setEntries(Array.isArray(data) ? data : []))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load audit log'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [tab, serverId]);
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-gb-bg border border-gb-bg-t w-[700px] max-h-[80vh] flex flex-col font-mono"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||
<span className="text-sm text-gb-orange">SERVER SETTINGS: {server?.name ?? serverId}</span>
|
||||
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
||||
</div>
|
||||
<div className="flex border-b border-gb-bg-t">
|
||||
<button
|
||||
onClick={() => setTab('audit')}
|
||||
className={`px-4 py-2 text-xs ${tab === 'audit' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
|
||||
>
|
||||
[AUDIT LOG]
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{tab === 'audit' && (
|
||||
<>
|
||||
{loading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
|
||||
{error && <p className="text-gb-red text-xs">ERR: {error}</p>}
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<p className="text-gb-fg-f text-xs">[no audit log entries]</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="text-xs border border-gb-bg-t p-2 bg-gb-bg-s">
|
||||
<div className="flex items-center justify-between text-gb-fg-f">
|
||||
<span>{formatTime(entry.created_at)}</span>
|
||||
<span className="text-gb-orange">{entry.action_type}</span>
|
||||
</div>
|
||||
<div className="text-gb-fg mt-1">
|
||||
<span className="text-gb-aqua">{entry.username ?? entry.user_id ?? 'system'}</span>
|
||||
{' '}<span className="text-gb-fg-s">{ACTION_LABELS[entry.action_type] ?? entry.action_type}</span>
|
||||
{entry.target_id && (
|
||||
<span className="text-gb-fg-f"> target:{entry.target_id}</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.reason && (
|
||||
<div className="text-gb-fg-f mt-1">reason: {entry.reason}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,10 @@ export interface SearchResultMessage extends Message {
|
||||
channel_name?: string;
|
||||
}
|
||||
|
||||
interface MessageState {
|
||||
export interface MessageState {
|
||||
messagesByChannel: Record<string, Message[]>;
|
||||
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
||||
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchMessages: (channelId: string, before?: string) => Promise<void>;
|
||||
@@ -38,11 +39,15 @@ interface MessageState {
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (message: Message) => void;
|
||||
removeMessage: (channelId: string, messageId: string) => void;
|
||||
bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>;
|
||||
toggleSelectedMessage: (channelId: string, messageId: string) => void;
|
||||
clearSelectedMessages: (channelId: string) => void;
|
||||
}
|
||||
|
||||
export const useMessageStore = create<MessageState>((set) => ({
|
||||
messagesByChannel: {},
|
||||
searchResultsByChannel: {},
|
||||
selectedMessageIds: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
@@ -140,4 +145,49 @@ export const useMessageStore = create<MessageState>((set) => ({
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
bulkDeleteMessages: async (channelId, messageIds) => {
|
||||
const res = await api.post<{ deleted: number }>(
|
||||
`/channels/${channelId}/messages/bulk-delete`,
|
||||
{ messages: messageIds },
|
||||
);
|
||||
set((state) => {
|
||||
const list = state.messagesByChannel[channelId] || [];
|
||||
const ids = new Set(messageIds);
|
||||
const selected = { ...state.selectedMessageIds };
|
||||
delete selected[channelId];
|
||||
return {
|
||||
messagesByChannel: {
|
||||
...state.messagesByChannel,
|
||||
[channelId]: list.filter((m) => !ids.has(m.id)),
|
||||
},
|
||||
selectedMessageIds: selected,
|
||||
};
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
toggleSelectedMessage: (channelId, messageId) =>
|
||||
set((state) => {
|
||||
const current = state.selectedMessageIds[channelId] || new Set<string>();
|
||||
const next = new Set(current);
|
||||
if (next.has(messageId)) {
|
||||
next.delete(messageId);
|
||||
} else {
|
||||
next.add(messageId);
|
||||
}
|
||||
return {
|
||||
selectedMessageIds: {
|
||||
...state.selectedMessageIds,
|
||||
[channelId]: next,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
clearSelectedMessages: (channelId) =>
|
||||
set((state) => {
|
||||
const next = { ...state.selectedMessageIds };
|
||||
delete next[channelId];
|
||||
return { selectedMessageIds: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user