Initial Commit

This commit is contained in:
2026-03-06 11:54:19 -05:00
parent ee68c4704f
commit 4517746692
36 changed files with 4262 additions and 0 deletions

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a73e8" />
<meta name="description" content="TeamChat - Modern team messaging" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>TeamChat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

25
frontend/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "teamchat-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"socket.io-client": "^4.6.1",
"emoji-mart": "^5.5.2",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"papaparse": "^5.4.1",
"date-fns": "^3.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.1.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,37 @@
{
"name": "TeamChat",
"short_name": "TeamChat",
"description": "Modern team messaging application",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#1a73e8",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

80
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,80 @@
const CACHE_NAME = 'teamchat-v2';
const STATIC_ASSETS = ['/'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const url = event.request.url;
if (url.includes('/api/') || url.includes('/socket.io/') || url.includes('/manifest.json')) {
return;
}
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});
// Track badge count in SW
let badgeCount = 0;
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
badgeCount++;
// Update app badge (supported on Android Chrome and some desktop)
if (navigator.setAppBadge) {
navigator.setAppBadge(badgeCount).catch(() => {});
}
event.waitUntil(
self.registration.showNotification(data.title || 'New Message', {
body: data.body || '',
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { url: data.url || '/' },
tag: 'teamchat-message', // replaces previous notification instead of stacking
renotify: true, // still vibrate/sound even if replacing
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
badgeCount = 0;
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
const url = event.notification.data?.url || '/';
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.focus();
return;
}
}
return clients.openWindow(url);
})
);
});
// Clear badge when user opens the app
self.addEventListener('message', (event) => {
if (event.data?.type === 'CLEAR_BADGE') {
badgeCount = 0;
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
}
});

45
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,45 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext.jsx';
import { SocketProvider } from './contexts/SocketContext.jsx';
import { ToastProvider } from './contexts/ToastContext.jsx';
import Login from './pages/Login.jsx';
import Chat from './pages/Chat.jsx';
import ChangePassword from './pages/ChangePassword.jsx';
function ProtectedRoute({ children }) {
const { user, loading, mustChangePassword } = useAuth();
if (loading) return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" style={{ width: 36, height: 36 }} />
</div>
);
if (!user) return <Navigate to="/login" replace />;
if (mustChangePassword) return <Navigate to="/change-password" replace />;
return children;
}
function AuthRoute({ children }) {
const { user, loading, mustChangePassword } = useAuth();
if (loading) return null;
if (user && !mustChangePassword) return <Navigate to="/" replace />;
return children;
}
export default function App() {
return (
<BrowserRouter>
<ToastProvider>
<AuthProvider>
<SocketProvider>
<Routes>
<Route path="/login" element={<AuthRoute><Login /></AuthRoute>} />
<Route path="/change-password" element={<ChangePassword />} />
<Route path="/" element={<ProtectedRoute><Chat /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</SocketProvider>
</AuthProvider>
</ToastProvider>
</BrowserRouter>
);
}

View 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>
);
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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); }

View 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' });
}

View 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; }

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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' });
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,58 @@
import { createContext, useContext, useState, useEffect } from 'react';
import { api } from '../utils/api.js';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [mustChangePassword, setMustChangePassword] = useState(false);
useEffect(() => {
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
if (token) {
api.me()
.then(({ user }) => {
setUser(user);
setMustChangePassword(!!user.must_change_password);
})
.catch(() => {
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
})
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email, password, rememberMe) => {
const data = await api.login({ email, password, rememberMe });
if (rememberMe) {
localStorage.setItem('tc_token', data.token);
} else {
sessionStorage.setItem('tc_token', data.token);
}
setUser(data.user);
setMustChangePassword(!!data.mustChangePassword);
return data;
};
const logout = async () => {
try { await api.logout(); } catch {}
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
setUser(null);
setMustChangePassword(false);
};
const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates }));
return (
<AuthContext.Provider value={{ user, loading, mustChangePassword, setMustChangePassword, login, logout, updateUser }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

View File

@@ -0,0 +1,46 @@
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { io } from 'socket.io-client';
import { useAuth } from './AuthContext.jsx';
const SocketContext = createContext(null);
export function SocketProvider({ children }) {
const { user } = useAuth();
const socketRef = useRef(null);
const [connected, setConnected] = useState(false);
const [onlineUsers, setOnlineUsers] = useState(new Set());
useEffect(() => {
if (!user) {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setConnected(false);
}
return;
}
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
const socket = io('/', { auth: { token }, transports: ['websocket'] });
socketRef.current = socket;
socket.on('connect', () => {
setConnected(true);
socket.emit('users:online');
});
socket.on('disconnect', () => setConnected(false));
socket.on('users:online', ({ userIds }) => setOnlineUsers(new Set(userIds)));
socket.on('user:online', ({ userId }) => setOnlineUsers(prev => new Set([...prev, userId])));
socket.on('user:offline', ({ userId }) => setOnlineUsers(prev => { const s = new Set(prev); s.delete(userId); return s; }));
return () => { socket.disconnect(); socketRef.current = null; };
}, [user?.id]);
return (
<SocketContext.Provider value={{ socket: socketRef.current, connected, onlineUsers }}>
{children}
</SocketContext.Provider>
);
}
export const useSocket = () => useContext(SocketContext);

View File

@@ -0,0 +1,28 @@
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
const ToastContext = createContext(null);
let toastIdCounter = 0;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const toast = useCallback((msg, type = 'default', duration = 3000) => {
const id = ++toastIdCounter;
setToasts(prev => [...prev, { id, msg, type }]);
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), duration);
}, []);
return (
<ToastContext.Provider value={toast}>
{children}
<div className="toast-container">
{toasts.map(t => (
<div key={t.id} className={`toast ${t.type}`}>{t.msg}</div>
))}
</div>
</ToastContext.Provider>
);
}
export const useToast = () => useContext(ToastContext);

199
frontend/src/index.css Normal file
View File

@@ -0,0 +1,199 @@
@import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600;700&family=Roboto:wght@300;400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #1a73e8;
--primary-dark: #1557b0;
--primary-light: #e8f0fe;
--surface: #ffffff;
--surface-variant: #f8f9fa;
--background: #f1f3f4;
--border: #e0e0e0;
--text-primary: #202124;
--text-secondary: #5f6368;
--text-tertiary: #9aa0a6;
--error: #d93025;
--success: #188038;
--warning: #e37400;
--bubble-out: #1a73e8;
--bubble-in: #f1f3f4;
--radius: 8px;
--radius-lg: 16px;
--radius-xl: 24px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.12);
--shadow-md: 0 2px 8px rgba(0,0,0,0.15);
--shadow-lg: 0 4px 20px rgba(0,0,0,0.18);
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--font: 'Google Sans', 'Roboto', sans-serif;
}
html, body, #root { height: 100%; font-family: var(--font); color: var(--text-primary); background: var(--background); }
button { font-family: var(--font); cursor: pointer; border: none; background: none; }
input, textarea { font-family: var(--font); }
a { color: inherit; text-decoration: none; }
/* Scrollbars */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* Focus */
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Utils */
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.gap-1 { gap: 4px; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.relative { position: relative; }
.absolute { position: absolute; }
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
.flex-1 { flex: 1; min-width: 0; }
.shrink-0 { flex-shrink: 0; }
.text-sm { font-size: 13px; }
.text-xs { font-size: 11px; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.opacity-60 { opacity: 0.6; }
.pointer { cursor: pointer; }
.rounded { border-radius: var(--radius); }
.rounded-full { border-radius: 9999px; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 20px; border-radius: 20px; font-size: 14px; font-weight: 500;
transition: var(--transition); white-space: nowrap;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); box-shadow: var(--shadow-sm); }
.btn-secondary { background: transparent; color: var(--primary); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--primary-light); }
.btn-ghost { background: transparent; color: var(--text-secondary); }
.btn-ghost:hover { background: var(--background); color: var(--text-primary); }
.btn-danger { background: var(--error); color: white; }
.btn-danger:hover { opacity: 0.9; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-icon { padding: 8px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; }
.btn-icon:hover { background: var(--background); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Inputs */
.input {
width: 100%; padding: 10px 14px; border: 1px solid var(--border);
border-radius: var(--radius); font-size: 14px; background: white;
transition: border-color var(--transition);
color: var(--text-primary);
}
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(26,115,232,0.15); }
.input::placeholder { color: var(--text-tertiary); }
/* Card */
.card {
background: white; border-radius: var(--radius-lg); padding: 20px;
box-shadow: var(--shadow-sm); border: 1px solid var(--border);
}
/* Modal overlay */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
z-index: 1000; padding: 16px;
animation: fadeIn 150ms ease;
}
.modal {
background: white; border-radius: var(--radius-xl); padding: 24px;
width: 100%; max-width: 480px; box-shadow: var(--shadow-lg);
animation: slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1);
max-height: 90vh; overflow-y: auto;
}
.modal-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; }
/* Avatar */
.avatar {
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 14px; flex-shrink: 0; overflow: hidden;
background: var(--primary); color: white; user-select: none;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.avatar-sm { width: 32px; height: 32px; font-size: 12px; }
.avatar-md { width: 40px; height: 40px; font-size: 15px; }
.avatar-lg { width: 48px; height: 48px; font-size: 18px; }
.avatar-xl { width: 72px; height: 72px; font-size: 24px; }
/* Badge */
.badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px;
font-size: 11px; font-weight: 600; background: var(--primary); color: white;
}
/* Animations */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideIn { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
@keyframes spin { to { transform: rotate(360deg); } }
/* Loading */
.spinner {
width: 24px; height: 24px; border: 3px solid var(--border);
border-top-color: var(--primary); border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Toast */
.toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 12px 20px; border-radius: var(--radius); background: #323232; color: white;
font-size: 14px; box-shadow: var(--shadow-md); animation: slideIn 200ms ease;
max-width: 320px;
}
.toast.error { background: var(--error); }
.toast.success { background: var(--success); }
/* Chip */
.chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 16px; font-size: 12px; font-weight: 500;
background: var(--primary-light); color: var(--primary);
}
.chip-remove { cursor: pointer; opacity: 0.7; font-size: 14px; }
.chip-remove:hover { opacity: 1; }
/* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
/* Warning banner */
.warning-banner {
background: #fff3e0; border: 1px solid #ff9800; border-radius: var(--radius);
padding: 12px 16px; font-size: 13px; color: #e65100; display: flex; gap: 8px; align-items: flex-start;
}
/* Role badge */
.role-badge {
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; text-transform: uppercase;
}
.role-admin { background: #fce8e6; color: #c5221f; }
.role-member { background: var(--primary-light); color: var(--primary); }
.status-suspended { background: #fff3e0; color: #e65100; }
.settings-section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 12px;
}

25
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('[SW] Registered, scope:', reg.scope))
.catch(err => console.error('[SW] Registration failed:', err));
});
}
// Clear badge count when user focuses the app
window.addEventListener('focus', () => {
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
navigator.serviceWorker?.controller?.postMessage({ type: 'CLEAR_BADGE' });
});
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
export default function ChangePassword() {
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const { setMustChangePassword } = useAuth();
const toast = useToast();
const nav = useNavigate();
const submit = async (e) => {
e.preventDefault();
if (next !== confirm) return toast('Passwords do not match', 'error');
if (next.length < 8) return toast('Password must be at least 8 characters', 'error');
setLoading(true);
try {
await api.changePassword({ currentPassword: current, newPassword: next });
setMustChangePassword(false);
toast('Password changed!', 'success');
nav('/');
} catch (err) {
toast(err.message, 'error');
} finally {
setLoading(false);
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--background)', padding: 20 }}>
<div className="card" style={{ width: '100%', maxWidth: 420 }}>
<h2 style={{ marginBottom: 8, fontSize: 22, fontWeight: 700 }}>Change Password</h2>
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
You must set a new password before continuing.
</p>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<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={current} onChange={e => setCurrent(e.target.value)} required />
</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={next} onChange={e => setNext(e.target.value)} required />
</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={confirm} onChange={e => setConfirm(e.target.value)} required />
</div>
<button className="btn btn-primary" type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Set New Password'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
.chat-layout {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--background);
}
@media (max-width: 767px) {
.chat-layout {
position: relative;
}
}

171
frontend/src/pages/Chat.jsx Normal file
View File

@@ -0,0 +1,171 @@
import { useState, useEffect, 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 Sidebar from '../components/Sidebar.jsx';
import ChatWindow from '../components/ChatWindow.jsx';
import ProfileModal from '../components/ProfileModal.jsx';
import UserManagerModal from '../components/UserManagerModal.jsx';
import SettingsModal from '../components/SettingsModal.jsx';
import NewChatModal from '../components/NewChatModal.jsx';
import './Chat.css';
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
return outputArray;
}
export default function Chat() {
const { socket } = useSocket();
const { user } = useAuth();
const toast = useToast();
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
const [activeGroupId, setActiveGroupId] = useState(null);
const [notifications, setNotifications] = useState([]);
const [unreadGroups, setUnreadGroups] = useState(new Set());
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat'
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true);
useEffect(() => {
const handle = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
if (!mobile) setShowSidebar(true);
};
window.addEventListener('resize', handle);
return () => window.removeEventListener('resize', handle);
}, []);
const loadGroups = useCallback(() => {
api.getGroups().then(setGroups).catch(() => {});
}, []);
useEffect(() => { loadGroups(); }, [loadGroups]);
// Register push subscription
useEffect(() => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
(async () => {
try {
const reg = await navigator.serviceWorker.ready;
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
const existing = await reg.pushManager.getSubscription();
if (existing) {
// Re-register to keep subscription fresh
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
body: JSON.stringify(existing.toJSON())
});
return;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
body: JSON.stringify(sub.toJSON())
});
console.log('[Push] Subscribed');
} catch (e) {
console.warn('[Push] Subscription failed:', e.message);
}
})();
}, []);
// Socket message events to update group previews
useEffect(() => {
if (!socket) return;
const handleNewMsg = (msg) => {
setGroups(prev => {
const updateGroup = (g) => g.id === msg.group_id
? { ...g, last_message: msg.content || (msg.image_url ? '📷 Image' : ''), last_message_at: msg.created_at }
: g;
return {
publicGroups: prev.publicGroups.map(updateGroup),
privateGroups: prev.privateGroups.map(updateGroup),
};
});
};
const handleNotification = (notif) => {
if (notif.type === 'private_message') {
// Show unread dot on private group in sidebar (if not currently viewing it)
setUnreadGroups(prev => {
if (notif.groupId === activeGroupId) return prev;
const next = new Set(prev);
next.add(notif.groupId);
return next;
});
} else {
setNotifications(prev => [notif, ...prev]);
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
}
};
socket.on('message:new', handleNewMsg);
socket.on('notification:new', handleNotification);
return () => {
socket.off('message:new', handleNewMsg);
socket.off('notification:new', handleNotification);
};
}, [socket, toast]);
const selectGroup = (id) => {
setActiveGroupId(id);
if (isMobile) setShowSidebar(false);
// Clear notifications for this group
setNotifications(prev => prev.filter(n => n.groupId !== id));
setUnreadGroups(prev => { const next = new Set(prev); next.delete(id); return next; });
};
const activeGroup = [
...(groups.publicGroups || []),
...(groups.privateGroups || [])
].find(g => g.id === activeGroupId);
return (
<div className="chat-layout">
{(!isMobile || showSidebar) && (
<Sidebar
groups={groups}
activeGroupId={activeGroupId}
onSelectGroup={selectGroup}
notifications={notifications}
unreadGroups={unreadGroups}
onNewChat={() => setModal('newchat')}
onProfile={() => setModal('profile')}
onUsers={() => setModal('users')}
onSettings={() => setModal('settings')}
onGroupsUpdated={loadGroups}
/>
)}
{(!isMobile || !showSidebar) && (
<ChatWindow
group={activeGroup}
onBack={isMobile ? () => setShowSidebar(true) : null}
onGroupUpdated={loadGroups}
/>
)}
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
</div>
);
}

View File

@@ -0,0 +1,106 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e8f0fe 0%, #f1f3f4 50%, #e8f0fe 100%);
padding: 20px;
}
.login-card {
background: white;
border-radius: 24px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.logo-img {
width: 72px;
height: 72px;
border-radius: 16px;
object-fit: cover;
margin-bottom: 16px;
}
.default-logo svg {
width: 72px;
height: 72px;
margin-bottom: 16px;
}
.login-logo h1 {
font-size: 28px;
font-weight: 700;
color: #202124;
margin-bottom: 4px;
}
.login-logo p {
color: #5f6368;
font-size: 15px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 14px;
font-weight: 500;
color: #5f6368;
}
.remember-me {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #5f6368;
cursor: pointer;
}
.remember-me input[type="checkbox"] {
accent-color: #1a73e8;
width: 16px;
height: 16px;
}
.login-footer {
margin-top: 24px;
text-align: center;
font-size: 13px;
color: #9aa0a6;
display: flex;
flex-direction: column;
gap: 4px;
}
.support-link {
background: none;
border: none;
color: var(--primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 0;
text-decoration: underline;
text-underline-offset: 2px;
}
.support-link:hover {
color: var(--primary-dark, #1557b0);
}

View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
import './Login.css';
import SupportModal from '../components/SupportModal.jsx';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
const [showSupport, setShowSupport] = useState(false);
const [settings, setSettings] = useState({});
const { login } = useAuth();
const toast = useToast();
const nav = useNavigate();
useEffect(() => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const data = await login(email, password, rememberMe);
if (data.mustChangePassword) {
nav('/change-password');
} else {
nav('/');
}
} catch (err) {
if (err.message === 'suspended') {
toast(`Your account has been suspended. Contact: ${err.adminEmail || 'your admin'} for assistance.`, 'error', 8000);
} else {
toast(err.message || 'Login failed', 'error');
}
} finally {
setLoading(false);
}
};
// Handle suspension error from API directly
const handleLoginError = async (email, password, rememberMe) => {
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, rememberMe })
});
const data = await res.json();
if (!res.ok) {
if (data.error === 'suspended') {
toast(`Your account has been suspended. Contact ${data.adminEmail || 'your administrator'} for assistance.`, 'error', 8000);
} else {
toast(data.error || 'Login failed', 'error');
}
return;
}
// Success handled by login function above
} finally {
setLoading(false);
}
};
const appName = settings.app_name || 'TeamChat';
const logoUrl = settings.logo_url;
return (
<div className="login-page">
<div className="login-card">
<div className="login-logo">
{logoUrl ? (
<img src={logoUrl} alt={appName} className="logo-img" />
) : (
<div className="default-logo">
<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>
)}
<h1>{appName}</h1>
<p>Sign in to continue</p>
</div>
{settings.pw_reset_active === 'true' && (
<div className="warning-banner" style={{ marginBottom: 16 }}>
<span></span>
<span><strong>PW_RESET is enabled.</strong> The admin password is being reset on each restart. Disable PW_RESET in your environment to stop this behavior.</span>
</div>
)}
<form onSubmit={handleSubmit} className="login-form">
<div className="field">
<label>Email</label>
<input className="input" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus placeholder="your@email.com" />
</div>
<div className="field">
<label>Password</label>
<input className="input" type="password" value={password} onChange={e => setPassword(e.target.value)} required placeholder="••••••••" />
</div>
<label className="remember-me">
<input type="checkbox" checked={rememberMe} onChange={e => setRememberMe(e.target.checked)} />
<span>Remember me</span>
</label>
<button className="btn btn-primary w-full" type="submit" disabled={loading}>
{loading ? <span className="spinner" style={{ width: 18, height: 18 }} /> : 'Sign in'}
</button>
</form>
<div className="login-footer">
<button className="support-link" onClick={() => setShowSupport(true)}>
Need help? Contact Support
</button>
</div>
{showSupport && <SupportModal onClose={() => setShowSupport(false)} />}
</div>
</div>
);
}

98
frontend/src/utils/api.js Normal file
View File

@@ -0,0 +1,98 @@
const BASE = '/api';
function getToken() {
return localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
}
async function req(method, path, body, opts = {}) {
const token = getToken();
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
let fetchOpts = { method, headers };
if (body instanceof FormData) {
fetchOpts.body = body;
} else if (body) {
headers['Content-Type'] = 'application/json';
fetchOpts.body = JSON.stringify(body);
}
const res = await fetch(BASE + path, fetchOpts);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Request failed');
return data;
}
export const api = {
// Auth
login: (body) => req('POST', '/auth/login', body),
submitSupport: (body) => req('POST', '/auth/support', body),
logout: () => req('POST', '/auth/logout'),
me: () => req('GET', '/auth/me'),
changePassword: (body) => req('POST', '/auth/change-password', body),
// Users
getUsers: () => req('GET', '/users'),
searchUsers: (q) => req('GET', `/users/search?q=${encodeURIComponent(q)}`),
createUser: (body) => req('POST', '/users', body),
bulkUsers: (users) => req('POST', '/users/bulk', { users }),
updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }),
resetPassword: (id, password) => req('PATCH', `/users/${id}/reset-password`, { password }),
suspendUser: (id) => req('PATCH', `/users/${id}/suspend`),
activateUser: (id) => req('PATCH', `/users/${id}/activate`),
deleteUser: (id) => req('DELETE', `/users/${id}`),
updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag }
uploadAvatar: (file) => {
const form = new FormData(); form.append('avatar', file);
return req('POST', '/users/me/avatar', form);
},
// Groups
getGroups: () => req('GET', '/groups'),
createGroup: (body) => req('POST', '/groups', body),
renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }),
getMembers: (id) => req('GET', `/groups/${id}/members`),
addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }),
leaveGroup: (id) => req('DELETE', `/groups/${id}/leave`),
takeOwnership: (id) => req('POST', `/groups/${id}/take-ownership`),
deleteGroup: (id) => req('DELETE', `/groups/${id}`),
// Messages
getMessages: (groupId, before) => req('GET', `/messages/group/${groupId}${before ? `?before=${before}` : ''}`),
sendMessage: (groupId, body) => req('POST', `/messages/group/${groupId}`, body),
uploadImage: (groupId, file, extra = {}) => {
const form = new FormData();
form.append('image', file);
if (extra.replyToId) form.append('replyToId', extra.replyToId);
if (extra.content) form.append('content', extra.content);
return req('POST', `/messages/group/${groupId}/image`, form);
},
deleteMessage: (id) => req('DELETE', `/messages/${id}`),
toggleReaction: (id, emoji) => req('POST', `/messages/${id}/reactions`, { emoji }),
// Settings
getSettings: () => req('GET', '/settings'),
updateAppName: (name) => req('PATCH', '/settings/app-name', { name }),
uploadLogo: (file) => {
const form = new FormData(); form.append('logo', file);
return req('POST', '/settings/logo', form);
},
uploadIconNewChat: (file) => {
const form = new FormData(); form.append('icon', file);
return req('POST', '/settings/icon-newchat', form);
},
uploadIconGroupInfo: (file) => {
const form = new FormData(); form.append('icon', file);
return req('POST', '/settings/icon-groupinfo', form);
},
resetSettings: () => req('POST', '/settings/reset'),
// Push notifications
getPushKey: () => req('GET', '/push/vapid-public'),
subscribePush: (sub) => req('POST', '/push/subscribe', sub),
unsubscribePush: (endpoint) => req('POST', '/push/unsubscribe', { endpoint }),
// Link preview
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
};

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'socket': ['socket.io-client'],
'emoji': ['emoji-mart', '@emoji-mart/data', '@emoji-mart/react'],
}
}
}
},
server: {
proxy: {
'/api': 'http://localhost:3000',
'/uploads': 'http://localhost:3000',
'/socket.io': { target: 'http://localhost:3000', ws: true }
}
}
});