Files
dumpsterChat/web/src/components/ServerSettingsModal.tsx
T

452 lines
18 KiB
TypeScript

import { useEffect, useState } from 'react';
import { api } from '../lib/api.ts';
import { useServerStore } from '../stores/server.ts';
import { usePermissions } from '../lib/usePermissions.ts';
import { RoleManager } from './RoleManager.tsx';
interface ServerGroup {
id: string;
server_id: string;
name: string;
position: number;
}
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;
}
interface AvailabilitySlot {
day_of_week: string;
start_time: string;
end_time: 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',
};
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
export function ServerSettingsModal({ serverId, onClose }: ServerSettingsModalProps) {
const [tab, setTab] = useState<'audit' | 'availability' | 'roles' | 'groups'>('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);
const { isOwner, canManageRoles, canManageChannels, canManageServer } = usePermissions(serverId);
// Availability state
const [slots, setSlots] = useState<AvailabilitySlot[]>([]);
const [availLoading, setAvailLoading] = useState(false);
const [availMessage, setAvailMessage] = useState<string | null>(null);
const [editDay, setEditDay] = useState('monday');
const [editStart, setEditStart] = useState('09:00');
const [editEnd, setEditEnd] = useState('17:00');
// Groups state
const [groups, setGroups] = useState<ServerGroup[]>([]);
const [groupName, setGroupName] = useState('');
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editingGroupName, setEditingGroupName] = useState('');
const [groupsLoading, setGroupsLoading] = useState(false);
const handleClose = () => {
window.dispatchEvent(new CustomEvent('refresh-server-groups'));
onClose();
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
handleClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
if (tab === 'audit' && (isOwner || canManageServer)) {
setLoading(true);
setError(null);
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));
} else if (tab === 'availability') {
setAvailLoading(true);
api.get<AvailabilitySlot[]>(`/users/me/availability?server_id=${serverId}`)
.then((data) => setSlots(Array.isArray(data) ? data : []))
.catch(() => {})
.finally(() => setAvailLoading(false));
} else if (tab === 'groups' && (isOwner || canManageChannels)) {
setGroupsLoading(true);
setError(null);
api.get<ServerGroup[]>(`/servers/${serverId}/groups`)
.then((data) => setGroups(Array.isArray(data) ? data : []))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load groups'))
.finally(() => setGroupsLoading(false));
}
}, [tab, serverId, isOwner, canManageServer, canManageChannels]);
const formatTime = (iso: string) => {
const d = new Date(iso);
return d.toLocaleString();
};
const addSlot = () => {
if (slots.some((s) => s.day_of_week === editDay && s.start_time === editStart)) return;
setSlots([...slots, { day_of_week: editDay, start_time: editStart, end_time: editEnd }]);
};
const removeSlot = (idx: number) => {
setSlots(slots.filter((_, i) => i !== idx));
};
const saveSlots = async () => {
setAvailMessage(null);
try {
await api.put(`/users/me/availability?server_id=${serverId}`, slots);
setAvailMessage('[saved]');
} catch (err) {
setAvailMessage(err instanceof Error ? `ERR: ${err.message}` : 'ERR: failed to save');
}
};
const slotsForDay = (day: string) => slots.filter((s) => s.day_of_week === day);
const handleCreateGroup = async (e: React.FormEvent) => {
e.preventDefault();
if (!groupName.trim()) return;
setError(null);
try {
await api.post(`/servers/${serverId}/groups`, { name: groupName.trim() });
setGroupName('');
const data = await api.get<ServerGroup[]>(`/servers/${serverId}/groups`);
setGroups(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
}
};
const handleRenameGroup = async (groupId: string, name: string) => {
if (!name.trim()) return;
setError(null);
try {
await api.patch(`/servers/${serverId}/groups/${groupId}`, { name: name.trim() });
setEditingGroupId(null);
const data = await api.get<ServerGroup[]>(`/servers/${serverId}/groups`);
setGroups(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to rename group');
}
};
const handleDeleteGroup = async (groupId: string) => {
if (!confirm('Are you sure you want to delete this group? Channels inside will be moved to ungrouped.')) return;
setError(null);
try {
await api.delete(`/servers/${serverId}/groups/${groupId}`);
const data = await api.get<ServerGroup[]>(`/servers/${serverId}/groups`);
setGroups(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete group');
}
};
const handleMoveGroup = async (groupId: string, direction: 'up' | 'down') => {
const idx = groups.findIndex((g) => g.id === groupId);
if (idx === -1) return;
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= groups.length) return;
setError(null);
const g1 = groups[idx];
const g2 = groups[targetIdx];
try {
await api.patch(`/servers/${serverId}/groups/${g1.id}`, { position: g2.position });
await api.patch(`/servers/${serverId}/groups/${g2.id}`, { position: g1.position });
const data = await api.get<ServerGroup[]>(`/servers/${serverId}/groups`);
setGroups(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reorder group');
}
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleClose}
>
<div
className="bg-gb-bg border border-gb-bg-t w-[750px] max-h-[85vh] 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={handleClose} className="text-xs text-gb-red">[x]</button>
</div>
<div className="flex border-b border-gb-bg-t">
{(isOwner || canManageServer) && (
<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>
)}
<button
onClick={() => setTab('availability')}
className={`px-4 py-2 text-xs ${tab === 'availability' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[AVAILABILITY]
</button>
{(isOwner || canManageRoles) && (
<button
onClick={() => setTab('roles')}
className={`px-4 py-2 text-xs ${tab === 'roles' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[ROLES]
</button>
)}
{(isOwner || canManageChannels) && (
<button
onClick={() => setTab('groups')}
className={`px-4 py-2 text-xs ${tab === 'groups' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'}`}
>
[GROUPS]
</button>
)}
</div>
<div className="flex-1 overflow-y-auto p-4">
{error && <p className="text-gb-red text-xs mb-3">ERR: {error}</p>}
{tab === 'audit' && (
<>
{loading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
{!loading && 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>
</>
)}
{tab === 'availability' && (
<div className="space-y-4">
{availLoading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
<div className="text-xs text-gb-fg-s mb-2">set your weekly availability for this server</div>
{/* Add slot form */}
<div className="flex items-center gap-2 flex-wrap">
<select
value={editDay}
onChange={(e) => setEditDay(e.target.value)}
className="terminal-input text-xs py-1 px-2"
>
{DAYS.map((d) => <option key={d} value={d}>{d.slice(0,3)}</option>)}
</select>
<input
type="time"
value={editStart}
onChange={(e) => setEditStart(e.target.value)}
className="terminal-input text-xs py-1 px-2 w-20"
/>
<span className="text-gb-fg-f text-xs">to</span>
<input
type="time"
value={editEnd}
onChange={(e) => setEditEnd(e.target.value)}
className="terminal-input text-xs py-1 px-2 w-20"
/>
<button onClick={addSlot} className="px-2 py-1 bg-gb-orange text-gb-bg text-xs">[ADD]</button>
</div>
{/* Grid by day */}
<div className="grid grid-cols-7 gap-1">
{DAYS.map((day) => (
<div key={day} className="text-xs">
<div className="text-gb-orange font-bold mb-1 text-center">{day.slice(0,3)}</div>
<div className="space-y-1">
{slotsForDay(day).map((s, i) => {
const globalIdx = slots.findIndex((x) => x === slots.filter((sl) => sl.day_of_week === day)[i]);
return (
<div key={globalIdx} className="bg-gb-bg-t rounded px-1 py-0.5 flex items-center justify-between gap-1 group">
<span className="text-gb-fg-s">{s.start_time}-{s.end_time}</span>
<button
onClick={() => removeSlot(globalIdx)}
className="text-gb-fg-f hover:text-gb-red opacity-0 group-hover:opacity-100"
></button>
</div>
);
})}
</div>
</div>
))}
</div>
<div className="flex items-center gap-2">
<button onClick={saveSlots} className="px-3 py-1 bg-gb-orange text-gb-bg text-xs">[SAVE]</button>
{availMessage && <span className="text-xs text-gb-fg-s">{availMessage}</span>}
</div>
</div>
)}
{tab === 'roles' && (
<div className="h-[60vh] overflow-y-auto">
<RoleManager serverId={serverId} />
</div>
)}
{tab === 'groups' && (
<div className="space-y-4">
<p className="text-xs text-gb-fg-s">manage channel groups for layout organization</p>
{/* Create group form */}
<form onSubmit={handleCreateGroup} className="flex gap-2 items-center">
<input
type="text"
placeholder="group name..."
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="terminal-input text-xs px-2 py-1 flex-1"
/>
<button type="submit" className="px-3 py-1 bg-gb-orange text-gb-bg text-xs">[CREATE GROUP]</button>
</form>
{groupsLoading && <p className="text-gb-fg-f text-xs">[loading...]</p>}
{!groupsLoading && groups.length === 0 && (
<p className="text-gb-fg-f text-xs">[no groups created yet]</p>
)}
<div className="space-y-2 mt-2">
{groups.map((g, index) => {
const isEditing = editingGroupId === g.id;
return (
<div key={g.id} className="flex items-center justify-between border border-gb-bg-t p-2 bg-gb-bg-s text-xs">
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<input
autoFocus
value={editingGroupName}
onChange={(e) => setEditingGroupName(e.target.value)}
className="bg-gb-bg-t text-gb-fg px-1 py-0.5 text-xs outline-none border border-gb-orange flex-1"
/>
<button
type="button"
onClick={() => handleRenameGroup(g.id, editingGroupName)}
className="text-gb-green hover:underline font-bold"
>
[SAVE]
</button>
<button
type="button"
onClick={() => setEditingGroupId(null)}
className="text-gb-fg-f hover:underline"
>
[CANCEL]
</button>
</div>
) : (
<>
<span className="text-gb-fg-s font-bold uppercase">{g.name}</span>
<div className="flex items-center gap-2">
<button
type="button"
disabled={index === 0}
onClick={() => handleMoveGroup(g.id, 'up')}
className="text-gb-fg-f hover:text-gb-orange disabled:opacity-30"
title="Move Up"
>
</button>
<button
type="button"
disabled={index === groups.length - 1}
onClick={() => handleMoveGroup(g.id, 'down')}
className="text-gb-fg-f hover:text-gb-orange disabled:opacity-30"
title="Move Down"
>
</button>
<button
type="button"
onClick={() => {
setEditingGroupId(g.id);
setEditingGroupName(g.name);
}}
className="text-gb-aqua hover:underline"
>
[RENAME]
</button>
<button
type="button"
onClick={() => handleDeleteGroup(g.id)}
className="text-gb-red hover:underline"
>
[DELETE]
</button>
</div>
</>
)}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
}