Initial Commit
This commit is contained in:
24
frontend/src/components/Avatar.jsx
Normal file
24
frontend/src/components/Avatar.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export default function Avatar({ user, size = 'md', className = '' }) {
|
||||
if (!user) return null;
|
||||
|
||||
const initials = (() => {
|
||||
const name = user.display_name || user.name || '';
|
||||
const parts = name.trim().split(' ').filter(Boolean);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return '??';
|
||||
})();
|
||||
|
||||
const colors = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
|
||||
const colorIdx = (user.name || '').charCodeAt(0) % colors.length;
|
||||
const bg = colors[colorIdx];
|
||||
|
||||
return (
|
||||
<div className={`avatar avatar-${size} ${className}`} style={{ background: user.avatar ? undefined : bg }}>
|
||||
{user.avatar
|
||||
? <img src={user.avatar} alt={initials} />
|
||||
: initials
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
frontend/src/components/ChatWindow.css
Normal file
142
frontend/src/components/ChatWindow.css
Normal file
@@ -0,0 +1,142 @@
|
||||
.chat-window {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-variant);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-window.empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p { font-size: 14px; }
|
||||
|
||||
/* Header */
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 64px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.group-icon-sm {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-header-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.readonly-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
align-self: center;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--primary-light);
|
||||
margin-bottom: 8px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.load-more-btn:hover { background: #d2e3fc; }
|
||||
|
||||
/* Typing indicator */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dots span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
animation: bounce 1.2s infinite;
|
||||
}
|
||||
.dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
/* Readonly bar */
|
||||
.readonly-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
252
frontend/src/components/ChatWindow.jsx
Normal file
252
frontend/src/components/ChatWindow.jsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import Message from './Message.jsx';
|
||||
import MessageInput from './MessageInput.jsx';
|
||||
import GroupInfoModal from './GroupInfoModal.jsx';
|
||||
import './ChatWindow.css';
|
||||
|
||||
export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
const { socket } = useSocket();
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
||||
const [typing, setTyping] = useState([]);
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesTopRef = useRef(null);
|
||||
const typingTimers = useRef({});
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||
}).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
|
||||
window.addEventListener('teamchat:settings-changed', handler);
|
||||
return () => window.removeEventListener('teamchat:settings-changed', handler);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) { setMessages([]); return; }
|
||||
setMessages([]);
|
||||
setHasMore(false);
|
||||
setLoading(true);
|
||||
api.getMessages(group.id)
|
||||
.then(({ messages }) => {
|
||||
setMessages(messages);
|
||||
setHasMore(messages.length >= 50);
|
||||
setTimeout(() => scrollToBottom(), 50);
|
||||
})
|
||||
.catch(e => toast(e.message, 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [group?.id]);
|
||||
|
||||
// Socket events
|
||||
useEffect(() => {
|
||||
if (!socket || !group) return;
|
||||
|
||||
const handleNew = (msg) => {
|
||||
if (msg.group_id !== group.id) return;
|
||||
setMessages(prev => {
|
||||
if (prev.find(m => m.id === msg.id)) return prev;
|
||||
return [...prev, msg];
|
||||
});
|
||||
setTimeout(() => scrollToBottom(true), 50);
|
||||
};
|
||||
|
||||
const handleDeleted = ({ messageId }) => {
|
||||
setMessages(prev => prev.filter(m => m.id !== messageId));
|
||||
};
|
||||
|
||||
const handleReaction = ({ messageId, reactions }) => {
|
||||
setMessages(prev => prev.map(m => m.id === messageId ? { ...m, reactions } : m));
|
||||
};
|
||||
|
||||
const handleTypingStart = ({ userId: tid, user: tu }) => {
|
||||
if (tid === user.id) return;
|
||||
setTyping(prev => prev.find(t => t.userId === tid) ? prev : [...prev, { userId: tid, name: tu?.display_name || tu?.name || 'Someone' }]);
|
||||
if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]);
|
||||
typingTimers.current[tid] = setTimeout(() => {
|
||||
setTyping(prev => prev.filter(t => t.userId !== tid));
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleTypingStop = ({ userId: tid }) => {
|
||||
setTyping(prev => prev.filter(t => t.userId !== tid));
|
||||
if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]);
|
||||
};
|
||||
|
||||
socket.on('message:new', handleNew);
|
||||
socket.on('message:deleted', handleDeleted);
|
||||
socket.on('reaction:updated', handleReaction);
|
||||
socket.on('typing:start', handleTypingStart);
|
||||
socket.on('typing:stop', handleTypingStop);
|
||||
|
||||
return () => {
|
||||
socket.off('message:new', handleNew);
|
||||
socket.off('message:deleted', handleDeleted);
|
||||
socket.off('reaction:updated', handleReaction);
|
||||
socket.off('typing:start', handleTypingStart);
|
||||
socket.off('typing:stop', handleTypingStop);
|
||||
};
|
||||
}, [socket, group?.id, user.id]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!messages.length) return;
|
||||
const oldest = messages[0];
|
||||
const { messages: older } = await api.getMessages(group.id, oldest.id);
|
||||
setMessages(prev => [...older, ...prev]);
|
||||
setHasMore(older.length >= 50);
|
||||
};
|
||||
|
||||
const handleSend = async ({ content, imageFile, linkPreview }) => {
|
||||
if (!group) return;
|
||||
const replyId = replyTo?.id;
|
||||
setReplyTo(null);
|
||||
|
||||
try {
|
||||
if (imageFile) {
|
||||
const { message } = await api.uploadImage(group.id, imageFile, { replyToId: replyId, content });
|
||||
// Add immediately to local state — don't wait for socket (it may be slow for large files)
|
||||
if (message) {
|
||||
setMessages(prev => prev.find(m => m.id === message.id) ? prev : [...prev, message]);
|
||||
setTimeout(() => scrollToBottom(true), 50);
|
||||
}
|
||||
} else {
|
||||
socket?.emit('message:send', {
|
||||
groupId: group.id, content, replyToId: replyId, linkPreview
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className="chat-window empty">
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" width="64" height="64">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Select a conversation</h3>
|
||||
<p>Choose from your existing chats or start a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-window">
|
||||
{/* Header */}
|
||||
<div className="chat-header">
|
||||
{onBack && (
|
||||
<button className="btn-icon" onClick={onBack}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="group-icon-sm"
|
||||
style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}
|
||||
>
|
||||
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="chat-header-name">{group.name}</span>
|
||||
{group.is_readonly ? (
|
||||
<span className="readonly-badge">Read-only</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="chat-header-sub">
|
||||
{group.type === 'public' ? 'Public channel' : 'Private group'}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info">
|
||||
{iconGroupInfo ? (
|
||||
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="24" height="24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="messages-container" ref={messagesTopRef}>
|
||||
{hasMore && (
|
||||
<button className="load-more-btn" onClick={loadMore}>Load older messages</button>
|
||||
)}
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((msg, i) => (
|
||||
<Message
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
prevMessage={messages[i - 1]}
|
||||
currentUser={user}
|
||||
onReply={(m) => setReplyTo(m)}
|
||||
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
|
||||
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
|
||||
/>
|
||||
))}
|
||||
{typing.length > 0 && (
|
||||
<div className="typing-indicator">
|
||||
<span>{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing</span>
|
||||
<span className="dots"><span/><span/><span/></span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{(!group.is_readonly || user.role === 'admin') ? (
|
||||
<MessageInput
|
||||
group={group}
|
||||
replyTo={replyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSend={handleSend}
|
||||
onTyping={(isTyping) => {
|
||||
if (socket) {
|
||||
if (isTyping) socket.emit('typing:start', { groupId: group.id });
|
||||
else socket.emit('typing:stop', { groupId: group.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="readonly-bar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
This channel is read-only
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInfo && (
|
||||
<GroupInfoModal
|
||||
group={group}
|
||||
onClose={() => setShowInfo(false)}
|
||||
onUpdated={onGroupUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/GroupInfoModal.jsx
Normal file
171
frontend/src/components/GroupInfoModal.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [members, setMembers] = useState([]);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [newName, setNewName] = useState(group.name);
|
||||
const [addSearch, setAddSearch] = useState('');
|
||||
const [addResults, setAddResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isOwner = group.owner_id === user.id;
|
||||
const isAdmin = user.role === 'admin';
|
||||
const canManage = (group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin);
|
||||
const canRename = !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner));
|
||||
|
||||
useEffect(() => {
|
||||
if (group.type === 'private') {
|
||||
api.getMembers(group.id).then(({ members }) => setMembers(members)).catch(() => {});
|
||||
}
|
||||
}, [group.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (addSearch) {
|
||||
api.searchUsers(addSearch).then(({ users }) => setAddResults(users)).catch(() => {});
|
||||
}
|
||||
}, [addSearch]);
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!newName.trim() || newName === group.name) { setEditing(false); return; }
|
||||
try {
|
||||
await api.renameGroup(group.id, newName.trim());
|
||||
toast('Group renamed', 'success');
|
||||
onUpdated();
|
||||
setEditing(false);
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!confirm('Leave this group?')) return;
|
||||
try {
|
||||
await api.leaveGroup(group.id);
|
||||
toast('Left group', 'success');
|
||||
onUpdated();
|
||||
onClose();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleTakeOwnership = async () => {
|
||||
if (!confirm('Take ownership of this private group? You will be able to see all messages.')) return;
|
||||
try {
|
||||
await api.takeOwnership(group.id);
|
||||
toast('Ownership taken', 'success');
|
||||
onUpdated();
|
||||
onClose();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleAdd = async (u) => {
|
||||
try {
|
||||
await api.addMember(group.id, u.id);
|
||||
toast(`${u.display_name || u.name} added`, 'success');
|
||||
api.getMembers(group.id).then(({ members }) => setMembers(members));
|
||||
setAddSearch('');
|
||||
setAddResults([]);
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Delete this group? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.deleteGroup(group.id);
|
||||
toast('Group deleted', 'success');
|
||||
onUpdated();
|
||||
onClose();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Group Info</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{editing ? (
|
||||
<div className="flex gap-2">
|
||||
<input className="input flex-1" value={newName} onChange={e => setNewName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleRename()} />
|
||||
<button className="btn btn-primary btn-sm" onClick={handleRename}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-8" style={{ gap: 12 }}>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 600, flex: 1 }}>{group.name}</h3>
|
||||
{canRename && (
|
||||
<button className="btn-icon" onClick={() => setEditing(true)} title="Rename">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-6" style={{ gap: 8, marginTop: 4 }}>
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{group.type === 'public' ? 'Public channel' : 'Private group'}
|
||||
</span>
|
||||
{group.is_readonly && <span className="readonly-badge" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: '#fff3e0', color: '#e65100' }}>Read-only</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members (private groups) */}
|
||||
{group.type === 'private' && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
Members ({members.length})
|
||||
</div>
|
||||
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{members.map(m => (
|
||||
<div key={m.id} className="flex items-center gap-2" style={{ gap: 10, padding: '6px 0' }}>
|
||||
<Avatar user={m} size="sm" />
|
||||
<span className="flex-1 text-sm">{m.display_name || m.name}</span>
|
||||
{m.id === group.owner_id && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<input className="input" placeholder="Search to add member..." value={addSearch} onChange={e => setAddSearch(e.target.value)} />
|
||||
{addResults.length > 0 && addSearch && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', marginTop: 4, maxHeight: 150, overflowY: 'auto' }}>
|
||||
{addResults.filter(u => !members.find(m => m.id === u.id)).map(u => (
|
||||
<button key={u.id} className="flex items-center gap-2 w-full" style={{ gap: 10, padding: '8px 12px', textAlign: 'left', transition: 'background var(--transition)' }} onClick={() => handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
|
||||
<Avatar user={u} size="sm" />
|
||||
<span className="text-sm flex-1">{u.display_name || u.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex-col gap-2">
|
||||
{group.type === 'private' && group.owner_id !== user.id && (
|
||||
<button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Group</button>
|
||||
)}
|
||||
{isAdmin && group.type === 'private' && group.owner_id !== user.id && (
|
||||
<button className="btn btn-secondary w-full" onClick={handleTakeOwnership}>
|
||||
Take Ownership (Admin)
|
||||
</button>
|
||||
)}
|
||||
{(isOwner || (isAdmin && group.type === 'public')) && !group.is_default && (
|
||||
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete Group</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/ImageLightbox.jsx
Normal file
78
frontend/src/components/ImageLightbox.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function ImageLightbox({ src, onClose }) {
|
||||
const overlayRef = useRef(null);
|
||||
const imgRef = useRef(null);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={(e) => e.target === overlayRef.current && onClose()}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 2000,
|
||||
background: 'rgba(0,0,0,0.92)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
touchAction: 'pinch-zoom',
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16,
|
||||
background: 'rgba(255,255,255,0.15)', border: 'none',
|
||||
borderRadius: '50%', width: 40, height: 40,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'white', zIndex: 2001,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Download button */}
|
||||
<a
|
||||
href={src}
|
||||
download
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 64,
|
||||
background: 'rgba(255,255,255,0.15)', border: 'none',
|
||||
borderRadius: '50%', width: 40, height: 40,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'white', zIndex: 2001, textDecoration: 'none',
|
||||
}}
|
||||
title="Download"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{/* Image — fit to screen, browser handles pinch-zoom natively */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt="Full size"
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 8,
|
||||
userSelect: 'none',
|
||||
touchAction: 'pinch-zoom',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
frontend/src/components/Message.css
Normal file
266
frontend/src/components/Message.css
Normal file
@@ -0,0 +1,266 @@
|
||||
.date-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.date-separator span {
|
||||
background: rgba(0,0,0,0.06);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-wrapper.own { flex-direction: row-reverse; }
|
||||
.message-wrapper.grouped { margin-top: 1px; }
|
||||
.message-wrapper:not(.grouped) { margin-top: 8px; }
|
||||
|
||||
.msg-avatar { flex-shrink: 0; }
|
||||
|
||||
.message-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 65%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.own .message-body { align-items: flex-end; }
|
||||
|
||||
.msg-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 3px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* Reply preview */
|
||||
.reply-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: rgba(0,0,0,0.05);
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: -4px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.reply-bar { width: 3px; background: var(--primary); border-radius: 2px; flex-shrink: 0; }
|
||||
|
||||
.reply-name { font-size: 11px; font-weight: 600; color: var(--primary); }
|
||||
.reply-text { font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
|
||||
|
||||
/* Bubble row */
|
||||
.msg-bubble-wrap {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.own .msg-bubble-wrap { flex-direction: row-reverse; }
|
||||
|
||||
/* Wrapper that holds the actions toolbar + bubble together */
|
||||
.msg-bubble-with-actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Actions toolbar — floats above the bubble */
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 4px 6px;
|
||||
box-shadow: var(--shadow-md);
|
||||
position: absolute;
|
||||
top: -36px;
|
||||
z-index: 20;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Own messages: toolbar anchors to the right edge of bubble */
|
||||
.msg-actions.actions-left { right: 0; }
|
||||
/* Other messages: toolbar anchors to the left edge of bubble */
|
||||
.msg-actions.actions-right { left: 0; }
|
||||
|
||||
.quick-emoji {
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.quick-emoji:hover { background: var(--background); transform: scale(1.2); }
|
||||
|
||||
.action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.action-btn:hover { color: var(--text-primary); }
|
||||
.action-btn.danger:hover { color: var(--error); }
|
||||
|
||||
/* Emoji picker — anchored relative to the toolbar */
|
||||
.emoji-picker-wrap {
|
||||
position: absolute;
|
||||
top: -360px; /* above the toolbar by default */
|
||||
z-index: 100;
|
||||
}
|
||||
.emoji-picker-wrap.picker-right { left: 0; }
|
||||
.emoji-picker-wrap.picker-left { right: 0; }
|
||||
/* When message is near top of window, open picker downward instead */
|
||||
.emoji-picker-wrap.picker-down {
|
||||
top: 36px;
|
||||
}
|
||||
|
||||
/* Bubble */
|
||||
.msg-bubble {
|
||||
padding: 8px 12px;
|
||||
border-radius: 18px;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.msg-bubble.out {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.msg-bubble.in {
|
||||
background: white;
|
||||
color: var(--text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.msg-bubble.deleted {
|
||||
background: transparent !important;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.deleted-text { font-size: 13px; color: var(--text-tertiary); font-style: italic; }
|
||||
|
||||
.msg-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mention {
|
||||
color: #1a5ca8;
|
||||
font-weight: 600;
|
||||
background: rgba(26,92,168,0.1);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.out .mention { color: #afd0ff; background: rgba(255,255,255,0.15); }
|
||||
|
||||
.msg-image {
|
||||
max-width: 240px;
|
||||
max-height: 240px;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Reactions */
|
||||
.reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.reaction-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.reaction-count { font-size: 12px; color: var(--text-secondary); }
|
||||
.reaction-btn.active { background: var(--primary-light); border-color: var(--primary); }
|
||||
.reaction-btn.active .reaction-count { color: var(--primary); }
|
||||
.reaction-btn:hover { background: var(--primary-light); }
|
||||
.reaction-remove {
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
margin-left: 1px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.reaction-btn:hover .reaction-remove { opacity: 1; }
|
||||
|
||||
/* Link preview */
|
||||
.link-preview {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background: rgba(0,0,0,0.06);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-top: 6px;
|
||||
text-decoration: none;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.link-preview:hover { background: rgba(0,0,0,0.1); }
|
||||
|
||||
.link-preview-img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.link-site { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.link-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.link-desc { font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.out .link-preview { background: rgba(255,255,255,0.15); }
|
||||
.out .link-title { color: white; }
|
||||
.out .link-desc { color: rgba(255,255,255,0.8); }
|
||||
249
frontend/src/components/Message.jsx
Normal file
249
frontend/src/components/Message.jsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import UserProfilePopup from './UserProfilePopup.jsx';
|
||||
import ImageLightbox from './ImageLightbox.jsx';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import data from '@emoji-mart/data';
|
||||
import './Message.css';
|
||||
|
||||
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
||||
|
||||
function formatMsgContent(content) {
|
||||
if (!content) return '';
|
||||
return content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `<span class="mention">@${name}</span>`);
|
||||
}
|
||||
|
||||
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact }) {
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const wrapperRef = useRef(null);
|
||||
const pickerRef = useRef(null);
|
||||
const avatarRef = useRef(null);
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [lightboxSrc, setLightboxSrc] = useState(null);
|
||||
const [pickerOpensDown, setPickerOpensDown] = useState(false);
|
||||
|
||||
const isOwn = msg.user_id === currentUser.id;
|
||||
const isDeleted = !!msg.is_deleted;
|
||||
|
||||
// Deleted messages are filtered out by ChatWindow, but guard here too
|
||||
if (isDeleted) return null;
|
||||
|
||||
const canDelete = (
|
||||
msg.user_id === currentUser.id ||
|
||||
(currentUser.role === 'admin' && msg.group_type !== 'private') ||
|
||||
(msg.group_owner_id === currentUser.id)
|
||||
);
|
||||
|
||||
const prevSameUser = prevMessage && prevMessage.user_id === msg.user_id &&
|
||||
new Date(msg.created_at) - new Date(prevMessage.created_at) < 60000;
|
||||
|
||||
const showDateSep = !prevMessage ||
|
||||
new Date(msg.created_at).toDateString() !== new Date(prevMessage.created_at).toDateString();
|
||||
|
||||
const reactionMap = {};
|
||||
for (const r of (msg.reactions || [])) {
|
||||
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, users: [], hasMe: false };
|
||||
reactionMap[r.emoji].count++;
|
||||
reactionMap[r.emoji].users.push(r.user_name);
|
||||
if (r.user_id === currentUser.id) reactionMap[r.emoji].hasMe = true;
|
||||
}
|
||||
|
||||
// Close emoji picker when clicking outside
|
||||
useEffect(() => {
|
||||
if (!showEmojiPicker) return;
|
||||
const handler = (e) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target)) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showEmojiPicker]);
|
||||
|
||||
const handleReact = (emoji) => {
|
||||
onReact(msg.id, emoji);
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showEmojiPicker && wrapperRef.current) {
|
||||
// If the message is in the top 400px of viewport, open picker downward
|
||||
const rect = wrapperRef.current.getBoundingClientRect();
|
||||
setPickerOpensDown(rect.top < 400);
|
||||
}
|
||||
setShowEmojiPicker(p => !p);
|
||||
};
|
||||
|
||||
const msgUser = {
|
||||
id: msg.user_id,
|
||||
name: msg.user_name,
|
||||
display_name: msg.user_display_name,
|
||||
avatar: msg.user_avatar,
|
||||
role: msg.user_role,
|
||||
status: msg.user_status,
|
||||
hide_admin_tag: msg.user_hide_admin_tag,
|
||||
about_me: msg.user_about_me,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDateSep && (
|
||||
<div className="date-separator">
|
||||
<span>{formatDate(msg.created_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
|
||||
>
|
||||
{!isOwn && !prevSameUser && (
|
||||
<div ref={avatarRef} style={{ cursor: 'pointer' }} onClick={() => setShowProfile(p => !p)}>
|
||||
<Avatar user={msgUser} size="sm" className="msg-avatar" />
|
||||
</div>
|
||||
)}
|
||||
{!isOwn && prevSameUser && <div style={{ width: 32, flexShrink: 0 }} />}
|
||||
|
||||
<div className="message-body">
|
||||
{!isOwn && !prevSameUser && (
|
||||
<div className="msg-name">
|
||||
{msgUser.display_name || msgUser.name}
|
||||
{msgUser.role === 'admin' && !msgUser.hide_admin_tag && <span className="role-badge role-admin" style={{ marginLeft: 6 }}>Admin</span>}
|
||||
{msgUser.status !== 'active' && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--text-tertiary)' }}>(inactive)</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply preview */}
|
||||
{msg.reply_to_id && (
|
||||
<div className="reply-preview">
|
||||
<div className="reply-bar" />
|
||||
<div>
|
||||
<div className="reply-name">{msg.reply_user_display_name || msg.reply_user_name}</div>
|
||||
<div className="reply-text">
|
||||
{msg.reply_is_deleted ? <em style={{ color: 'var(--text-tertiary)' }}>Deleted message</em>
|
||||
: msg.reply_image_url ? '📷 Image'
|
||||
: msg.reply_content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bubble + actions together so actions hover above bubble */}
|
||||
<div className="msg-bubble-wrap">
|
||||
<div className="msg-bubble-with-actions">
|
||||
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
|
||||
{!isDeleted && (showActions || showEmojiPicker) && (
|
||||
<div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}>
|
||||
{QUICK_EMOJIS.map(e => (
|
||||
<button key={e} className="quick-emoji" onClick={() => handleReact(e)} title={e}>{e}</button>
|
||||
))}
|
||||
<button className="btn-icon action-btn" onClick={handleTogglePicker} title="More reactions">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||
</button>
|
||||
<button className="btn-icon action-btn" onClick={() => onReply(msg)} title="Reply">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button className="btn-icon action-btn danger" onClick={() => onDelete(msg.id)} title="Delete">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Emoji picker anchored to the toolbar */}
|
||||
{showEmojiPicker && (
|
||||
<div
|
||||
className={`emoji-picker-wrap ${isOwn ? 'picker-left' : 'picker-right'} ${pickerOpensDown ? 'picker-down' : ''}`}
|
||||
ref={pickerRef}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<Picker data={data} onEmojiSelect={(e) => handleReact(e.native)} theme="light" previewPosition="none" skinTonePosition="none" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}`}>
|
||||
{msg.image_url && (
|
||||
<img
|
||||
src={msg.image_url}
|
||||
alt="attachment"
|
||||
className="msg-image"
|
||||
onClick={() => setLightboxSrc(msg.image_url)}
|
||||
/>
|
||||
)}
|
||||
{msg.content && (
|
||||
<p
|
||||
className="msg-text"
|
||||
dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }}
|
||||
/>
|
||||
)}
|
||||
{msg.link_preview && <LinkPreview data={msg.link_preview} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="msg-time">{formatTime(msg.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{Object.keys(reactionMap).length > 0 && (
|
||||
<div className="reactions">
|
||||
{Object.entries(reactionMap).map(([emoji, { count, users, hasMe }]) => (
|
||||
<button
|
||||
key={emoji}
|
||||
className={`reaction-btn ${hasMe ? 'active' : ''}`}
|
||||
onClick={() => onReact(msg.id, emoji)}
|
||||
title={hasMe ? `${users.join(', ')} · Click to remove` : users.join(', ')}
|
||||
>
|
||||
{emoji} <span className="reaction-count">{count}</span>
|
||||
{hasMe && <span className="reaction-remove" title="Remove reaction">×</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showProfile && (
|
||||
<UserProfilePopup
|
||||
user={msgUser}
|
||||
anchorEl={avatarRef.current}
|
||||
onClose={() => setShowProfile(false)}
|
||||
/>
|
||||
)}
|
||||
{lightboxSrc && (
|
||||
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPreview({ data: raw }) {
|
||||
let d;
|
||||
try { d = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; }
|
||||
if (!d?.title) return null;
|
||||
|
||||
return (
|
||||
<a href={d.url} target="_blank" rel="noopener noreferrer" className="link-preview">
|
||||
{d.image && <img src={d.image} alt="" className="link-preview-img" onError={e => e.target.style.display = 'none'} />}
|
||||
<div className="link-preview-content">
|
||||
{d.siteName && <span className="link-site">{d.siteName}</span>}
|
||||
<span className="link-title">{d.title}</span>
|
||||
{d.description && <span className="link-desc">{d.description}</span>}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return 'Today';
|
||||
const yest = new Date(now); yest.setDate(yest.getDate() - 1);
|
||||
if (d.toDateString() === yest.toDateString()) return 'Yesterday';
|
||||
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
}
|
||||
168
frontend/src/components/MessageInput.css
Normal file
168
frontend/src/components/MessageInput.css
Normal file
@@ -0,0 +1,168 @@
|
||||
.message-input-area {
|
||||
background: white;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reply-bar-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--primary-light);
|
||||
border-radius: var(--radius);
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.reply-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.reply-preview-text {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.img-preview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
background: var(--background);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.img-preview {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.link-preview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--background);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.link-prev-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mention-dropdown {
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mention-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mention-item:hover, .mention-item.active { background: var(--primary-light); }
|
||||
|
||||
.mention-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mention-role {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-action {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.input-action:hover { color: var(--primary); }
|
||||
|
||||
.input-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msg-input {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
font-family: var(--font);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-variant);
|
||||
transition: border-color var(--transition);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.msg-input:focus { outline: none; border-color: var(--primary); background: white; }
|
||||
.msg-input::placeholder { color: var(--text-tertiary); }
|
||||
|
||||
.send-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
transition: var(--transition);
|
||||
background: var(--background);
|
||||
}
|
||||
.send-btn.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
.send-btn.active:hover { background: var(--primary-dark); }
|
||||
.send-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
247
frontend/src/components/MessageInput.jsx
Normal file
247
frontend/src/components/MessageInput.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import './MessageInput.css';
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
||||
|
||||
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) {
|
||||
const [text, setText] = useState('');
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
const [mentionSearch, setMentionSearch] = useState('');
|
||||
const [mentionResults, setMentionResults] = useState([]);
|
||||
const [mentionIndex, setMentionIndex] = useState(-1);
|
||||
const [showMention, setShowMention] = useState(false);
|
||||
const [linkPreview, setLinkPreview] = useState(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const typingTimer = useRef(null);
|
||||
const wasTyping = useRef(false);
|
||||
const mentionStart = useRef(-1);
|
||||
const fileInput = useRef(null);
|
||||
|
||||
// Handle typing notification
|
||||
const handleTypingChange = (value) => {
|
||||
if (value && !wasTyping.current) {
|
||||
wasTyping.current = true;
|
||||
onTyping(true);
|
||||
}
|
||||
if (typingTimer.current) clearTimeout(typingTimer.current);
|
||||
typingTimer.current = setTimeout(() => {
|
||||
if (wasTyping.current) {
|
||||
wasTyping.current = false;
|
||||
onTyping(false);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Link preview
|
||||
const fetchPreview = useCallback(async (url) => {
|
||||
setLoadingPreview(true);
|
||||
try {
|
||||
const { preview } = await api.getLinkPreview(url);
|
||||
if (preview) setLinkPreview(preview);
|
||||
} catch {}
|
||||
setLoadingPreview(false);
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const val = e.target.value;
|
||||
setText(val);
|
||||
handleTypingChange(val);
|
||||
|
||||
// Detect @mention
|
||||
const cur = e.target.selectionStart;
|
||||
const lastAt = val.lastIndexOf('@', cur - 1);
|
||||
if (lastAt !== -1) {
|
||||
const between = val.slice(lastAt + 1, cur);
|
||||
if (!between.includes(' ') && !between.includes('\n')) {
|
||||
mentionStart.current = lastAt;
|
||||
setMentionSearch(between);
|
||||
setShowMention(true);
|
||||
api.searchUsers(between).then(({ users }) => {
|
||||
setMentionResults(users);
|
||||
setMentionIndex(0);
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setShowMention(false);
|
||||
|
||||
// Link preview
|
||||
const urls = val.match(URL_REGEX);
|
||||
if (urls && urls[0] !== linkPreview?.url) {
|
||||
fetchPreview(urls[0]);
|
||||
} else if (!urls) {
|
||||
setLinkPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const insertMention = (user) => {
|
||||
const before = text.slice(0, mentionStart.current);
|
||||
const after = text.slice(inputRef.current.selectionStart);
|
||||
const mention = `@[${user.display_name || user.name}](${user.id}) `;
|
||||
setText(before + mention + after);
|
||||
setShowMention(false);
|
||||
setMentionResults([]);
|
||||
inputRef.current.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (showMention && mentionResults.length > 0) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => Math.min(i + 1, mentionResults.length - 1)); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => Math.max(i - 1, 0)); return; }
|
||||
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (mentionIndex >= 0) insertMention(mentionResults[mentionIndex]); return; }
|
||||
if (e.key === 'Escape') { setShowMention(false); return; }
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed && !imageFile) return;
|
||||
|
||||
const lp = linkPreview;
|
||||
setText('');
|
||||
setLinkPreview(null);
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
wasTyping.current = false;
|
||||
onTyping(false);
|
||||
|
||||
await onSend({ content: trimmed || null, imageFile, linkPreview: lp });
|
||||
};
|
||||
|
||||
const compressImage = (file) => new Promise((resolve) => {
|
||||
const MAX_PX = 1920;
|
||||
const QUALITY = 0.82;
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
let { width, height } = img;
|
||||
if (width <= MAX_PX && height <= MAX_PX) {
|
||||
// Already small enough — still re-encode to strip EXIF and reduce size
|
||||
} else {
|
||||
const ratio = Math.min(MAX_PX / width, MAX_PX / height);
|
||||
width = Math.round(width * ratio);
|
||||
height = Math.round(height * ratio);
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(blob => {
|
||||
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
|
||||
}, 'image/jpeg', QUALITY);
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
const handleImageSelect = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const compressed = await compressImage(file);
|
||||
setImageFile(compressed);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setImagePreview(e.target.result);
|
||||
reader.readAsDataURL(compressed);
|
||||
};
|
||||
|
||||
const displayText = (t) => {
|
||||
// Convert @[name](id) to @name for display
|
||||
return t.replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="message-input-area">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div className="reply-bar-input">
|
||||
<div className="reply-indicator">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
||||
<span>Replying to <strong>{replyTo.user_display_name || replyTo.user_name}</strong></span>
|
||||
<span className="reply-preview-text">{replyTo.content?.slice(0, 60) || (replyTo.image_url ? '📷 Image' : '')}</span>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={onCancelReply}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image preview */}
|
||||
{imagePreview && (
|
||||
<div className="img-preview-bar">
|
||||
<img src={imagePreview} alt="preview" className="img-preview" />
|
||||
<button className="btn-icon" onClick={() => { setImageFile(null); setImagePreview(null); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link preview */}
|
||||
{linkPreview && (
|
||||
<div className="link-preview-bar">
|
||||
{linkPreview.image && <img src={linkPreview.image} alt="" className="link-prev-img" onError={e => e.target.style.display='none'} />}
|
||||
<div className="flex-col flex-1 overflow-hidden gap-1">
|
||||
{linkPreview.siteName && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', textTransform: 'uppercase' }}>{linkPreview.siteName}</span>}
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }} className="truncate">{linkPreview.title}</span>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setLinkPreview(null)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mention dropdown */}
|
||||
{showMention && mentionResults.length > 0 && (
|
||||
<div className="mention-dropdown">
|
||||
{mentionResults.map((u, i) => (
|
||||
<button
|
||||
key={u.id}
|
||||
className={`mention-item ${i === mentionIndex ? 'active' : ''}`}
|
||||
onMouseDown={(e) => { e.preventDefault(); insertMention(u); }}
|
||||
>
|
||||
<div className="mention-avatar">{(u.display_name || u.name)?.[0]?.toUpperCase()}</div>
|
||||
<span>{u.display_name || u.name}</span>
|
||||
<span className="mention-role">{u.role}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="input-row">
|
||||
<button className="btn-icon input-action" onClick={() => fileInput.current?.click()} title="Attach image">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</button>
|
||||
<input ref={fileInput} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageSelect} />
|
||||
|
||||
<div className="input-wrap">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="msg-input"
|
||||
placeholder={`Message ${group?.name || ''}...`}
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`send-btn ${(text.trim() || imageFile) ? 'active' : ''}`}
|
||||
onClick={handleSend}
|
||||
disabled={!text.trim() && !imageFile}
|
||||
title="Send"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
frontend/src/components/NewChatModal.jsx
Normal file
124
frontend/src/components/NewChatModal.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
export default function NewChatModal({ onClose, onCreated }) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [tab, setTab] = useState('private'); // 'private' | 'public'
|
||||
const [name, setName] = useState('');
|
||||
const [isReadonly, setIsReadonly] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
api.searchUsers(search).then(({ users }) => setUsers(users)).catch(() => {});
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return toast('Name required', 'error');
|
||||
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { group } = await api.createGroup({
|
||||
name: name.trim(),
|
||||
type: tab,
|
||||
memberIds: selected.map(u => u.id),
|
||||
isReadonly: tab === 'public' && isReadonly,
|
||||
});
|
||||
toast(`${tab === 'public' ? 'Channel' : 'Chat'} created!`, 'success');
|
||||
onCreated(group);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (u) => {
|
||||
if (u.id === user.id) return;
|
||||
setSelected(prev => prev.find(p => p.id === u.id) ? prev.filter(p => p.id !== u.id) : [...prev, u]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Start a Chat</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||
<button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Private Group</button>
|
||||
<button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Channel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{tab === 'public' ? 'Channel Name' : 'Group Name'}
|
||||
</label>
|
||||
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder={tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team'} autoFocus />
|
||||
</div>
|
||||
|
||||
{tab === 'public' && user.role === 'admin' && (
|
||||
<label className="flex items-center gap-2 text-sm" style={{ marginBottom: 16, cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
||||
<input type="checkbox" checked={isReadonly} onChange={e => setIsReadonly(e.target.checked)} />
|
||||
Read-only channel (only admins can post)
|
||||
</label>
|
||||
)}
|
||||
|
||||
{tab === 'private' && (
|
||||
<>
|
||||
<div className="flex-col gap-2" style={{ marginBottom: 12 }}>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Add Members</label>
|
||||
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="flex gap-2" style={{ flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{selected.map(u => (
|
||||
<span key={u.id} className="chip">
|
||||
{u.display_name || u.name}
|
||||
<span className="chip-remove" onClick={() => toggle(u)}>×</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
||||
{users.filter(u => u.id !== user.id).map(u => (
|
||||
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
|
||||
<Avatar user={u} size="sm" />
|
||||
<span className="flex-1 text-sm">{u.display_name || u.name}</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-between" style={{ marginTop: 20 }}>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/ProfileModal.jsx
Normal file
146
frontend/src/components/ProfileModal.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
export default function ProfileModal({ onClose }) {
|
||||
const { user, updateUser } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
const [displayName, setDisplayName] = useState(user?.display_name || '');
|
||||
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
|
||||
const [currentPw, setCurrentPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tab, setTab] = useState('profile'); // 'profile' | 'password'
|
||||
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag });
|
||||
updateUser(updated);
|
||||
toast('Profile updated', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const { avatarUrl } = await api.uploadAvatar(file);
|
||||
updateUser({ avatar: avatarUrl });
|
||||
toast('Avatar updated', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPw !== confirmPw) return toast('Passwords do not match', 'error');
|
||||
if (newPw.length < 8) return toast('Password too short (min 8)', 'error');
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.changePassword({ currentPassword: currentPw, newPassword: newPw });
|
||||
toast('Password changed', 'success');
|
||||
setCurrentPw(''); setNewPw(''); setConfirmPw('');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>My Profile</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-3" style={{ gap: 16, marginBottom: 20 }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Avatar user={user} size="xl" />
|
||||
<label title="Change avatar" style={{
|
||||
position: 'absolute', bottom: 0, right: 0,
|
||||
background: 'var(--primary)', color: 'white', borderRadius: '50%',
|
||||
width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 12
|
||||
}}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarUpload} />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 16 }}>{user?.display_name || user?.name}</div>
|
||||
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>{user?.email}</div>
|
||||
<span className={`role-badge role-${user?.role}`}>{user?.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button>
|
||||
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button>
|
||||
</div>
|
||||
|
||||
{tab === 'profile' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Display Name</label>
|
||||
<input className="input" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={user?.name} />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label>
|
||||
<textarea className="input" value={aboutMe} onChange={e => setAboutMe(e.target.value)} placeholder="Tell your team about yourself..." rows={3} style={{ resize: 'vertical' }} />
|
||||
</div>
|
||||
{user?.role === 'admin' && (
|
||||
<label className="flex items-center gap-2 text-sm pointer" style={{ color: 'var(--text-secondary)', userSelect: 'none' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideAdminTag}
|
||||
onChange={e => setHideAdminTag(e.target.checked)}
|
||||
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }}
|
||||
/>
|
||||
Hide "Admin" tag next to my name in messages
|
||||
</label>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'password' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label>
|
||||
<input className="input" type="password" value={currentPw} onChange={e => setCurrentPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label>
|
||||
<input className="input" type="password" value={newPw} onChange={e => setNewPw(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Confirm New Password</label>
|
||||
<input className="input" type="password" value={confirmPw} onChange={e => setConfirmPw(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleChangePassword} disabled={loading || !currentPw || !newPw}>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
frontend/src/components/SettingsModal.jsx
Normal file
243
frontend/src/components/SettingsModal.jsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
function IconUploadRow({ label, settingKey, currentUrl, onUploaded, defaultSvg }) {
|
||||
const toast = useToast();
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 1024 * 1024) return toast(`${label} icon must be less than 1MB`, 'error');
|
||||
try {
|
||||
let result;
|
||||
if (settingKey === 'icon_newchat') result = await api.uploadIconNewChat(file);
|
||||
else result = await api.uploadIconGroupInfo(file);
|
||||
onUploaded(settingKey, result.iconUrl);
|
||||
toast(`${label} icon updated`, 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 10, background: 'var(--background)',
|
||||
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||
}}>
|
||||
{currentUrl ? (
|
||||
<img src={currentUrl} alt={label} style={{ width: 32, height: 32, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<span style={{ opacity: 0.35 }}>{defaultSvg}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{label}</div>
|
||||
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||
Upload PNG
|
||||
<input type="file" accept="image/png,image/svg+xml,image/*" style={{ display: 'none' }} onChange={handleUpload} />
|
||||
</label>
|
||||
{currentUrl && (
|
||||
<span style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-tertiary)' }}>Custom icon active</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState({});
|
||||
const [appName, setAppName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setSettings(settings);
|
||||
setAppName(settings.app_name || 'TeamChat');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const notifySidebarRefresh = () => window.dispatchEvent(new Event('teamchat:settings-changed'));
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!appName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.updateAppName(appName.trim());
|
||||
setSettings(prev => ({ ...prev, app_name: appName.trim() }));
|
||||
toast('App name updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 1024 * 1024) return toast('Logo must be less than 1MB', 'error');
|
||||
try {
|
||||
const { logoUrl } = await api.uploadLogo(file);
|
||||
setSettings(prev => ({ ...prev, logo_url: logoUrl }));
|
||||
toast('Logo updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconUploaded = (key, url) => {
|
||||
setSettings(prev => ({ ...prev, [key]: url }));
|
||||
notifySidebarRefresh();
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.resetSettings();
|
||||
const { settings: fresh } = await api.getSettings();
|
||||
setSettings(fresh);
|
||||
setAppName(fresh.app_name || 'TeamChat');
|
||||
toast('Settings reset to defaults', 'success');
|
||||
notifySidebarRefresh();
|
||||
setShowResetConfirm(false);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const newChatSvg = (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
const groupInfoSvg = (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 460 }}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>App Settings</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* App Logo */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">App Logo</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: 16, background: 'var(--background)',
|
||||
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||
}}>
|
||||
{settings.logo_url ? (
|
||||
<img src={settings.logo_url} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<svg viewBox="0 0 48 48" fill="none" style={{ width: 48, height: 48 }}>
|
||||
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||
Upload Logo
|
||||
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||
</label>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>Square format, max 1MB. Used in sidebar, login page and browser tab.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App Name */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">App Name</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input className="input flex-1" value={appName} onChange={e => setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>
|
||||
{loading ? '...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Icons */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">Interface Icons</div>
|
||||
<IconUploadRow
|
||||
label="New Chat Button"
|
||||
settingKey="icon_newchat"
|
||||
currentUrl={settings.icon_newchat}
|
||||
onUploaded={handleIconUploaded}
|
||||
defaultSvg={newChatSvg}
|
||||
/>
|
||||
<IconUploadRow
|
||||
label="Group Info Button"
|
||||
settingKey="icon_groupinfo"
|
||||
currentUrl={settings.icon_groupinfo}
|
||||
onUploaded={handleIconUploaded}
|
||||
defaultSvg={groupInfoSvg}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset + Version */}
|
||||
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
|
||||
<div className="settings-section-label">Reset</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
{!showResetConfirm ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>
|
||||
Reset All to Defaults
|
||||
</button>
|
||||
) : (
|
||||
<div style={{
|
||||
background: '#fce8e6', border: '1px solid #f5c6c2',
|
||||
borderRadius: 'var(--radius)', padding: '12px 14px'
|
||||
}}>
|
||||
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
|
||||
This will reset the app name, logo, and all custom icons to their install defaults. This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
|
||||
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{settings.app_version && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||||
v{settings.app_version}
|
||||
</span>
|
||||
)}
|
||||
</div>{/* end flex row */}
|
||||
</div>{/* end Reset section */}
|
||||
|
||||
{settings.pw_reset_active === 'true' && (
|
||||
<div className="warning-banner">
|
||||
<span>⚠️</span>
|
||||
<span><strong>PW_RESET is active.</strong> The default admin password is being reset on every restart. Set PW_RESET=false in your environment variables to stop this.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
frontend/src/components/Sidebar.css
Normal file
201
frontend/src/components/Sidebar.css
Normal file
@@ -0,0 +1,201 @@
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
height: 100vh;
|
||||
background: white;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar { width: 100vw; min-width: 0; }
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.offline-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 36px;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
background: var(--background);
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font);
|
||||
}
|
||||
.search-input:focus { outline: none; }
|
||||
.search-input::placeholder { color: var(--text-tertiary); }
|
||||
|
||||
.groups-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.group-section { margin-bottom: 8px; }
|
||||
|
||||
.section-label {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
.group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
border-radius: 0;
|
||||
}
|
||||
.group-item:hover { background: var(--background); }
|
||||
.group-item.active { background: var(--primary-light); }
|
||||
.group-item.active .group-name { color: var(--primary); font-weight: 600; }
|
||||
|
||||
.group-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.group-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-last-msg {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.sidebar-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--transition);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.user-footer-btn:hover { background: var(--background); }
|
||||
|
||||
.footer-menu {
|
||||
position: absolute;
|
||||
bottom: 68px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
z-index: 100;
|
||||
animation: slideUp 150ms ease;
|
||||
}
|
||||
|
||||
.footer-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.footer-menu-item:hover { background: var(--background); }
|
||||
.footer-menu-item.danger { color: var(--error); }
|
||||
.footer-menu-item.danger:hover { background: #fce8e6; }
|
||||
|
||||
/* App logo in sidebar header */
|
||||
.sidebar-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo-default {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo-default svg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: block;
|
||||
}
|
||||
222
frontend/src/components/Sidebar.jsx
Normal file
222
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import './Sidebar.css';
|
||||
|
||||
function useAppSettings() {
|
||||
const [settings, setSettings] = useState({ app_name: 'TeamChat', logo_url: '' });
|
||||
|
||||
const fetchSettings = () => {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
// Re-fetch when settings are saved from the SettingsModal
|
||||
window.addEventListener('teamchat:settings-changed', fetchSettings);
|
||||
return () => window.removeEventListener('teamchat:settings-changed', fetchSettings);
|
||||
}, []);
|
||||
|
||||
// Update page title and favicon whenever settings change
|
||||
useEffect(() => {
|
||||
const name = settings.app_name || 'TeamChat';
|
||||
|
||||
// Update <title>
|
||||
document.title = name;
|
||||
|
||||
// Update favicon
|
||||
const logoUrl = settings.logo_url;
|
||||
const faviconUrl = logoUrl || '/logo.svg';
|
||||
let link = document.querySelector("link[rel~='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = faviconUrl;
|
||||
}, [settings]);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Set(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { connected } = useSocket();
|
||||
const toast = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const settings = useAppSettings();
|
||||
|
||||
const appName = settings.app_name || 'TeamChat';
|
||||
const logoUrl = settings.logo_url;
|
||||
|
||||
const allGroups = [
|
||||
...(groups.publicGroups || []),
|
||||
...(groups.privateGroups || [])
|
||||
];
|
||||
|
||||
const filtered = search
|
||||
? allGroups.filter(g => g.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: allGroups;
|
||||
|
||||
const publicFiltered = filtered.filter(g => g.type === 'public');
|
||||
const privateFiltered = filtered.filter(g => g.type === 'private');
|
||||
|
||||
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const GroupItem = ({ group }) => {
|
||||
const notifs = getNotifCount(group.id);
|
||||
const hasUnread = unreadGroups.has(group.id);
|
||||
const isActive = group.id === activeGroupId;
|
||||
|
||||
return (
|
||||
<div className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`} onClick={() => onSelectGroup(group.id)}>
|
||||
<div className="group-icon" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}>
|
||||
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="group-info flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>{group.name}</span>
|
||||
{group.last_message_at && (
|
||||
<span className="group-time">{formatTime(group.last_message_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="group-last-msg truncate">
|
||||
{group.last_message || (group.is_readonly ? '📢 Read-only' : 'No messages yet')}
|
||||
</span>
|
||||
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
|
||||
{hasUnread && notifs === 0 && <span className="unread-dot shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
{/* Header with live app name and logo */}
|
||||
<div className="sidebar-header">
|
||||
<div className="flex items-center gap-2 flex-1" style={{ minWidth: 0 }}>
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={appName} className="sidebar-logo" />
|
||||
) : (
|
||||
<div className="sidebar-logo-default">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="sidebar-title truncate">{appName}</h2>
|
||||
{!connected && <span className="offline-dot" title="Offline" />}
|
||||
</div>
|
||||
<button className="btn-icon" onClick={onNewChat} title="New Chat">
|
||||
{settings.icon_newchat ? (
|
||||
<img src={settings.icon_newchat} alt="New Chat" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="sidebar-search">
|
||||
<div className="search-wrap">
|
||||
<svg className="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input className="search-input" placeholder="Search chats..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups list */}
|
||||
<div className="groups-list">
|
||||
{publicFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">CHANNELS</div>
|
||||
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{privateFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">DIRECT MESSAGES</div>
|
||||
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
|
||||
No chats found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="sidebar-footer">
|
||||
<button className="user-footer-btn" onClick={() => setShowMenu(!showMenu)}>
|
||||
<Avatar user={user} size="sm" />
|
||||
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
|
||||
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
|
||||
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="footer-menu" onClick={() => setShowMenu(false)}>
|
||||
<button className="footer-menu-item" onClick={onProfile}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Profile
|
||||
</button>
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<button className="footer-menu-item" onClick={onUsers}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
User Manager
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={onOpenSettings}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
|
||||
Settings
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||
<button className="footer-menu-item danger" onClick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
if (diff < 86400000 && date.getDate() === now.getDate()) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
if (diff < 604800000) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
199
frontend/src/components/SupportModal.jsx
Normal file
199
frontend/src/components/SupportModal.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
function generateCaptcha() {
|
||||
const a = Math.floor(Math.random() * 9) + 1;
|
||||
const b = Math.floor(Math.random() * 9) + 1;
|
||||
const ops = [
|
||||
{ label: `${a} + ${b}`, answer: a + b },
|
||||
{ label: `${a + b} - ${b}`, answer: a },
|
||||
{ label: `${a} × ${b}`, answer: a * b },
|
||||
];
|
||||
return ops[Math.floor(Math.random() * ops.length)];
|
||||
}
|
||||
|
||||
export default function SupportModal({ onClose }) {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [captcha, setCaptcha] = useState(generateCaptcha);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const refreshCaptcha = () => {
|
||||
setCaptcha(generateCaptcha());
|
||||
setCaptchaAnswer('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!name.trim() || !email.trim() || !message.trim()) {
|
||||
return setError('Please fill in all fields.');
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return setError('Please enter a valid email address.');
|
||||
}
|
||||
if (parseInt(captchaAnswer, 10) !== captcha.answer) {
|
||||
setError('Incorrect answer — please try again.');
|
||||
refreshCaptcha();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.submitSupport({ name, email, message });
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to send. Please try again.');
|
||||
refreshCaptcha();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 440 }}>
|
||||
{sent ? (
|
||||
/* Success state */
|
||||
<div style={{ textAlign: 'center', padding: '8px 0 16px' }}>
|
||||
<div style={{
|
||||
width: 56, height: 56, borderRadius: '50%',
|
||||
background: '#e6f4ea', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', margin: '0 auto 16px'
|
||||
}}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34a853" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Message Sent</h3>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 24, lineHeight: 1.5 }}>
|
||||
Your message has been received. An administrator will follow up with you shortly.
|
||||
</p>
|
||||
<button className="btn btn-primary" onClick={onClose} style={{ minWidth: 120 }}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Form state */
|
||||
<>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Contact Support</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 20 }}>
|
||||
Fill out the form below and an administrator will get back to you.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Your Name</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Jane Smith"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
autoFocus
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Your Email</label>
|
||||
<input
|
||||
className="input"
|
||||
type="email"
|
||||
placeholder="jane@example.com"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder="Describe your issue or question..."
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)', alignSelf: 'flex-end' }}>
|
||||
{message.length}/2000
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Math captcha */}
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Security Check
|
||||
</label>
|
||||
<div className="flex items-center gap-2" style={{ gap: 10 }}>
|
||||
<div style={{
|
||||
background: 'var(--background)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)', padding: '9px 16px',
|
||||
fontSize: 15, fontWeight: 700, letterSpacing: 2,
|
||||
color: 'var(--text-primary)', fontFamily: 'monospace',
|
||||
flexShrink: 0, userSelect: 'none'
|
||||
}}>
|
||||
{captcha.label} = ?
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="Answer"
|
||||
value={captchaAnswer}
|
||||
onChange={e => setCaptchaAnswer(e.target.value)}
|
||||
style={{ width: 90 }}
|
||||
min={0}
|
||||
max={999}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={refreshCaptcha}
|
||||
title="New question"
|
||||
style={{ color: 'var(--text-secondary)', flexShrink: 0 }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#fce8e6', border: '1px solid #f5c6c2',
|
||||
borderRadius: 'var(--radius)', padding: '10px 14px',
|
||||
fontSize: 13, color: 'var(--error)'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn btn-primary" type="submit" disabled={loading} style={{ marginTop: 4 }}>
|
||||
{loading
|
||||
? <><span className="spinner" style={{ width: 16, height: 16 }} /> Sending...</>
|
||||
: 'Send Message'
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
288
frontend/src/components/UserManagerModal.jsx
Normal file
288
frontend/src/components/UserManagerModal.jsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
export default function UserManagerModal({ onClose }) {
|
||||
const { user: me } = useAuth();
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tab, setTab] = useState('users'); // 'users' | 'create' | 'bulk'
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' });
|
||||
const [bulkPreview, setBulkPreview] = useState([]);
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const [resetingId, setResetingId] = useState(null);
|
||||
const [resetPw, setResetPw] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const load = () => {
|
||||
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const filtered = users.filter(u =>
|
||||
!search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name || !form.email || !form.password) return toast('All fields required', 'error');
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createUser(form);
|
||||
toast('User created', 'success');
|
||||
setForm({ name: '', email: '', password: '', role: 'member' });
|
||||
setTab('users');
|
||||
load();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCSV = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
complete: ({ data }) => {
|
||||
const rows = data.filter(r => r.email).map(r => ({
|
||||
name: r.name || r.Name || '',
|
||||
email: r.email || r.Email || '',
|
||||
password: r.password || r.Password || 'TempPass@123',
|
||||
role: (r.role || r.Role || 'member').toLowerCase(),
|
||||
}));
|
||||
setBulkPreview(rows);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkImport = async () => {
|
||||
if (!bulkPreview.length) return;
|
||||
setBulkLoading(true);
|
||||
try {
|
||||
const results = await api.bulkUsers(bulkPreview);
|
||||
toast(`Created: ${results.created.length}, Errors: ${results.errors.length}`, results.errors.length ? 'default' : 'success');
|
||||
setBulkPreview([]);
|
||||
setTab('users');
|
||||
load();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setBulkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRole = async (u, role) => {
|
||||
try {
|
||||
await api.updateRole(u.id, role);
|
||||
toast('Role updated', 'success');
|
||||
load();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPw = async (uid) => {
|
||||
if (!resetPw || resetPw.length < 6) return toast('Enter a password (min 6 chars)', 'error');
|
||||
try {
|
||||
await api.resetPassword(uid, resetPw);
|
||||
toast('Password reset — user must change on next login', 'success');
|
||||
setResetingId(null);
|
||||
setResetPw('');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuspend = async (u) => {
|
||||
if (!confirm(`Suspend ${u.name}?`)) return;
|
||||
try { await api.suspendUser(u.id); toast('User suspended', 'success'); load(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleActivate = async (u) => {
|
||||
try { await api.activateUser(u.id); toast('User activated', 'success'); load(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (u) => {
|
||||
if (!confirm(`Delete ${u.name}? Their messages will remain but they cannot log in.`)) return;
|
||||
try { await api.deleteUser(u.id); toast('User deleted', 'success'); load(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 700 }}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||
<button className={`btn btn-sm ${tab === 'users' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('users')}>
|
||||
All Users ({users.length})
|
||||
</button>
|
||||
<button className={`btn btn-sm ${tab === 'create' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('create')}>
|
||||
+ Create User
|
||||
</button>
|
||||
<button className={`btn btn-sm ${tab === 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('bulk')}>
|
||||
Bulk Import CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Users list */}
|
||||
{tab === 'users' && (
|
||||
<>
|
||||
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
{loading ? (
|
||||
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 440, overflowY: 'auto' }}>
|
||||
{filtered.map(u => (
|
||||
<div key={u.id} style={{ borderBottom: '1px solid var(--border)', padding: '12px 0' }}>
|
||||
<div className="flex items-center gap-2" style={{ gap: 12 }}>
|
||||
<Avatar user={u} size="sm" />
|
||||
<div className="flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2" style={{ gap: 8 }}>
|
||||
<span className="font-medium text-sm">{u.display_name || u.name}</span>
|
||||
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
||||
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
|
||||
{u.is_default_admin ? <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Default Admin</span> : null}
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{u.email}</span>
|
||||
{u.must_change_password ? <span className="text-xs" style={{ color: 'var(--warning)' }}>⚠ Must change password</span> : null}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!u.is_default_admin && (
|
||||
<div className="flex gap-1" style={{ gap: 4 }}>
|
||||
{resetingId === u.id ? (
|
||||
<div className="flex gap-1" style={{ gap: 4 }}>
|
||||
<input className="input" style={{ width: 130, fontSize: 12, padding: '4px 8px' }} type="password" placeholder="New password" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleResetPw(u.id)}>Set</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setResetingId(null); setResetPw(''); }}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setResetingId(u.id)} title="Reset password">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
Reset PW
|
||||
</button>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => handleRole(u, e.target.value)}
|
||||
className="input"
|
||||
style={{ width: 90, padding: '4px 6px', fontSize: 12 }}
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
{u.status === 'active' ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleSuspend(u)}>Suspend</button>
|
||||
) : u.status === 'suspended' ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleActivate(u)} style={{ color: 'var(--success)' }}>Activate</button>
|
||||
) : null}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(u)}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create user */}
|
||||
{tab === 'create' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div className="flex gap-3" style={{ gap: 12 }}>
|
||||
<div className="flex-col gap-1 flex-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Full Name</label>
|
||||
<input className="input" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex-col gap-1 flex-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Email</label>
|
||||
<input className="input" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3" style={{ gap: 12 }}>
|
||||
<div className="flex-col gap-1 flex-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password</label>
|
||||
<input className="input" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Role</label>
|
||||
<select className="input" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))}>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>User will be required to change their password on first login.</p>
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={creating}>{creating ? 'Creating...' : 'Create User'}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk import */}
|
||||
{tab === 'bulk' && (
|
||||
<div className="flex-col gap-4">
|
||||
<div className="card" style={{ background: 'var(--background)', border: '1px dashed var(--border)' }}>
|
||||
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>CSV Format</p>
|
||||
<code style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'block', background: 'white', padding: 8, borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
name,email,password,role{'\n'}
|
||||
John Doe,john@example.com,TempPass123,member
|
||||
</code>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||
role can be "member" or "admin". Password defaults to TempPass@123 if omitted. All users must change password on first login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="btn btn-secondary pointer" style={{ alignSelf: 'flex-start' }}>
|
||||
Select CSV File
|
||||
<input ref={fileRef} type="file" accept=".csv" style={{ display: 'none' }} onChange={handleCSV} />
|
||||
</label>
|
||||
|
||||
{bulkPreview.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>Preview ({bulkPreview.length} users)</p>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', maxHeight: 200, overflowY: 'auto' }}>
|
||||
{bulkPreview.slice(0, 10).map((u, i) => (
|
||||
<div key={i} className="flex items-center gap-2" style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 13, gap: 12 }}>
|
||||
<span className="flex-1">{u.name}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{u.email}</span>
|
||||
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
||||
</div>
|
||||
))}
|
||||
{bulkPreview.length > 10 && (
|
||||
<div style={{ padding: '8px 12px', color: 'var(--text-tertiary)', fontSize: 13 }}>
|
||||
...and {bulkPreview.length - 10} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleBulkImport} disabled={bulkLoading}>
|
||||
{bulkLoading ? 'Importing...' : `Import ${bulkPreview.length} Users`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/UserProfilePopup.jsx
Normal file
81
frontend/src/components/UserProfilePopup.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
||||
const popupRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (popupRef.current && !popupRef.current.contains(e.target) &&
|
||||
anchorEl && !anchorEl.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [anchorEl, onClose]);
|
||||
|
||||
// Position near the anchor element
|
||||
useEffect(() => {
|
||||
if (!popupRef.current || !anchorEl) return;
|
||||
const anchor = anchorEl.getBoundingClientRect();
|
||||
const popup = popupRef.current;
|
||||
const viewportH = window.innerHeight;
|
||||
const viewportW = window.innerWidth;
|
||||
|
||||
// Default: below and to the right of avatar
|
||||
let top = anchor.bottom + 8;
|
||||
let left = anchor.left;
|
||||
|
||||
// Flip up if not enough space below
|
||||
if (top + 220 > viewportH) top = anchor.top - 228;
|
||||
// Clamp right edge
|
||||
if (left + 220 > viewportW) left = viewportW - 228;
|
||||
|
||||
popup.style.top = `${top}px`;
|
||||
popup.style.left = `${left}px`;
|
||||
}, [anchorEl]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 1000,
|
||||
background: 'white',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||
width: 220,
|
||||
padding: '20px 16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Avatar user={user} size="xl" />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)', marginBottom: 2 }}>
|
||||
{user.display_name || user.name}
|
||||
</div>
|
||||
{user.role === 'admin' && !user.hide_admin_tag && (
|
||||
<span className="role-badge role-admin" style={{ fontSize: 11 }}>Admin</span>
|
||||
)}
|
||||
</div>
|
||||
{user.about_me && (
|
||||
<p style={{
|
||||
fontSize: 13, color: 'var(--text-secondary)',
|
||||
textAlign: 'center', lineHeight: 1.5,
|
||||
marginTop: 4, wordBreak: 'break-word',
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingTop: 10, width: '100%'
|
||||
}}>
|
||||
{user.about_me}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user