diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..1898338
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+ TeamChat
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..c99edac
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png
new file mode 100644
index 0000000..7887c7e
Binary files /dev/null and b/frontend/public/icons/icon-192.png differ
diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png
new file mode 100644
index 0000000..b0b341a
Binary files /dev/null and b/frontend/public/icons/icon-512.png differ
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
new file mode 100644
index 0000000..a0c2d06
--- /dev/null
+++ b/frontend/public/manifest.json
@@ -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"
+ }
+ ]
+}
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000..9b9de64
--- /dev/null
+++ b/frontend/public/sw.js
@@ -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(() => {});
+ }
+});
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..df4f2e9
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -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 (
+
+ );
+ if (!user) return ;
+ if (mustChangePassword) return ;
+ return children;
+}
+
+function AuthRoute({ children }) {
+ const { user, loading, mustChangePassword } = useAuth();
+ if (loading) return null;
+ if (user && !mustChangePassword) return ;
+ return children;
+}
+
+export default function App() {
+ return (
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Avatar.jsx b/frontend/src/components/Avatar.jsx
new file mode 100644
index 0000000..41ad42a
--- /dev/null
+++ b/frontend/src/components/Avatar.jsx
@@ -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 (
+
+ {user.avatar
+ ?
+ : initials
+ }
+
+ );
+}
diff --git a/frontend/src/components/ChatWindow.css b/frontend/src/components/ChatWindow.css
new file mode 100644
index 0000000..065ee3f
--- /dev/null
+++ b/frontend/src/components/ChatWindow.css
@@ -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);
+}
diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx
new file mode 100644
index 0000000..3bcd600
--- /dev/null
+++ b/frontend/src/components/ChatWindow.jsx
@@ -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 (
+
+
+
+
Select a conversation
+
Choose from your existing chats or start a new one
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+ {onBack && (
+
+
+
+ )}
+
+ {group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
+
+
+
+ {group.name}
+ {group.is_readonly ? (
+ Read-only
+ ) : null}
+
+
+ {group.type === 'public' ? 'Public channel' : 'Private group'}
+
+
+
setShowInfo(true)} title="Group info">
+ {iconGroupInfo ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {/* Messages */}
+
+ {hasMore && (
+
Load older messages
+ )}
+ {loading ? (
+
+ ) : (
+ <>
+ {messages.map((msg, i) => (
+
setReplyTo(m)}
+ onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
+ onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
+ />
+ ))}
+ {typing.length > 0 && (
+
+ {typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing
+
+
+ )}
+
+ >
+ )}
+
+
+ {/* Input */}
+ {(!group.is_readonly || user.role === 'admin') ? (
+
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 });
+ }
+ }}
+ />
+ ) : (
+
+
+ This channel is read-only
+
+ )}
+
+ {showInfo && (
+ setShowInfo(false)}
+ onUpdated={onGroupUpdated}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/components/GroupInfoModal.jsx b/frontend/src/components/GroupInfoModal.jsx
new file mode 100644
index 0000000..3a011cd
--- /dev/null
+++ b/frontend/src/components/GroupInfoModal.jsx
@@ -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 (
+ e.target === e.currentTarget && onClose()}>
+
+
+
Group Info
+
+
+
+
+
+ {/* Name */}
+
+ {editing ? (
+
+ setNewName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleRename()} />
+ Save
+ setEditing(false)}>Cancel
+
+ ) : (
+
+
{group.name}
+ {canRename && (
+
setEditing(true)} title="Rename">
+
+
+ )}
+
+ )}
+
+
+ {group.type === 'public' ? 'Public channel' : 'Private group'}
+
+ {group.is_readonly && Read-only }
+
+
+
+ {/* Members (private groups) */}
+ {group.type === 'private' && (
+
+
+ Members ({members.length})
+
+
+ {members.map(m => (
+
+
+
{m.display_name || m.name}
+ {m.id === group.owner_id &&
Owner }
+
+ ))}
+
+
+ {canManage && (
+
+
setAddSearch(e.target.value)} />
+ {addResults.length > 0 && addSearch && (
+
+ {addResults.filter(u => !members.find(m => m.id === u.id)).map(u => (
+
handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
+
+ {u.display_name || u.name}
+
+ ))}
+
+ )}
+
+ )}
+
+ )}
+
+ {/* Actions */}
+
+ {group.type === 'private' && group.owner_id !== user.id && (
+ Leave Group
+ )}
+ {isAdmin && group.type === 'private' && group.owner_id !== user.id && (
+
+ Take Ownership (Admin)
+
+ )}
+ {(isOwner || (isAdmin && group.type === 'public')) && !group.is_default && (
+ Delete Group
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ImageLightbox.jsx b/frontend/src/components/ImageLightbox.jsx
new file mode 100644
index 0000000..de843f0
--- /dev/null
+++ b/frontend/src/components/ImageLightbox.jsx
@@ -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 (
+ 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 */}
+
+
+
+
+
+
+ {/* Download button */}
+
+
+
+
+
+
+
+
+ {/* Image โ fit to screen, browser handles pinch-zoom natively */}
+
e.stopPropagation()}
+ />
+
+ );
+}
diff --git a/frontend/src/components/Message.css b/frontend/src/components/Message.css
new file mode 100644
index 0000000..1f7f573
--- /dev/null
+++ b/frontend/src/components/Message.css
@@ -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); }
diff --git a/frontend/src/components/Message.jsx b/frontend/src/components/Message.jsx
new file mode 100644
index 0000000..8a96e2b
--- /dev/null
+++ b/frontend/src/components/Message.jsx
@@ -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) => `@${name} `);
+}
+
+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 && (
+
+ {formatDate(msg.created_at)}
+
+ )}
+
+ setShowActions(true)}
+ onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
+ >
+ {!isOwn && !prevSameUser && (
+
setShowProfile(p => !p)}>
+
+
+ )}
+ {!isOwn && prevSameUser &&
}
+
+
+ {!isOwn && !prevSameUser && (
+
+ {msgUser.display_name || msgUser.name}
+ {msgUser.role === 'admin' && !msgUser.hide_admin_tag && Admin }
+ {msgUser.status !== 'active' && (inactive) }
+
+ )}
+
+ {/* Reply preview */}
+ {msg.reply_to_id && (
+
+
+
+
{msg.reply_user_display_name || msg.reply_user_name}
+
+ {msg.reply_is_deleted ? Deleted message
+ : msg.reply_image_url ? '๐ท Image'
+ : msg.reply_content}
+
+
+
+ )}
+
+ {/* Bubble + actions together so actions hover above bubble */}
+
+
+ {/* Actions toolbar โ floats above the bubble, aligned to correct side */}
+ {!isDeleted && (showActions || showEmojiPicker) && (
+
+ {QUICK_EMOJIS.map(e => (
+
handleReact(e)} title={e}>{e}
+ ))}
+
+
+
+
onReply(msg)} title="Reply">
+
+
+ {canDelete && (
+
onDelete(msg.id)} title="Delete">
+
+
+ )}
+
+ {/* Emoji picker anchored to the toolbar */}
+ {showEmojiPicker && (
+
e.stopPropagation()}
+ >
+
handleReact(e.native)} theme="light" previewPosition="none" skinTonePosition="none" />
+
+ )}
+
+ )}
+
+
+ {msg.image_url && (
+
setLightboxSrc(msg.image_url)}
+ />
+ )}
+ {msg.content && (
+
+ )}
+ {msg.link_preview &&
}
+
+
+
+
{formatTime(msg.created_at)}
+
+
+ {Object.keys(reactionMap).length > 0 && (
+
+ {Object.entries(reactionMap).map(([emoji, { count, users, hasMe }]) => (
+ onReact(msg.id, emoji)}
+ title={hasMe ? `${users.join(', ')} ยท Click to remove` : users.join(', ')}
+ >
+ {emoji} {count}
+ {hasMe && ร }
+
+ ))}
+
+ )}
+
+
+ {showProfile && (
+ setShowProfile(false)}
+ />
+ )}
+ {lightboxSrc && (
+ 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 (
+
+ {d.image && e.target.style.display = 'none'} />}
+
+ {d.siteName && {d.siteName} }
+ {d.title}
+ {d.description && {d.description} }
+
+
+ );
+}
+
+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' });
+}
diff --git a/frontend/src/components/MessageInput.css b/frontend/src/components/MessageInput.css
new file mode 100644
index 0000000..17df452
--- /dev/null
+++ b/frontend/src/components/MessageInput.css
@@ -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; }
diff --git a/frontend/src/components/MessageInput.jsx b/frontend/src/components/MessageInput.jsx
new file mode 100644
index 0000000..6c71901
--- /dev/null
+++ b/frontend/src/components/MessageInput.jsx
@@ -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 (
+
+ {/* Reply preview */}
+ {replyTo && (
+
+
+
+
Replying to {replyTo.user_display_name || replyTo.user_name}
+
{replyTo.content?.slice(0, 60) || (replyTo.image_url ? '๐ท Image' : '')}
+
+
+
+
+
+ )}
+
+ {/* Image preview */}
+ {imagePreview && (
+
+
+
{ setImageFile(null); setImagePreview(null); }}>
+
+
+
+ )}
+
+ {/* Link preview */}
+ {linkPreview && (
+
+ {linkPreview.image &&
e.target.style.display='none'} />}
+
+ {linkPreview.siteName && {linkPreview.siteName} }
+ {linkPreview.title}
+
+
setLinkPreview(null)}>
+
+
+
+ )}
+
+ {/* Mention dropdown */}
+ {showMention && mentionResults.length > 0 && (
+
+ {mentionResults.map((u, i) => (
+
{ e.preventDefault(); insertMention(u); }}
+ >
+ {(u.display_name || u.name)?.[0]?.toUpperCase()}
+ {u.display_name || u.name}
+ {u.role}
+
+ ))}
+
+ )}
+
+
+
fileInput.current?.click()} title="Attach image">
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/NewChatModal.jsx b/frontend/src/components/NewChatModal.jsx
new file mode 100644
index 0000000..4816d72
--- /dev/null
+++ b/frontend/src/components/NewChatModal.jsx
@@ -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 (
+ e.target === e.currentTarget && onClose()}>
+
+
+
Start a Chat
+
+
+
+
+
+ {user.role === 'admin' && (
+
+ setTab('private')}>Private Group
+ setTab('public')}>Public Channel
+
+ )}
+
+
+
+ {tab === 'public' ? 'Channel Name' : 'Group Name'}
+
+ setName(e.target.value)} placeholder={tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team'} autoFocus />
+
+
+ {tab === 'public' && user.role === 'admin' && (
+
+ setIsReadonly(e.target.checked)} />
+ Read-only channel (only admins can post)
+
+ )}
+
+ {tab === 'private' && (
+ <>
+
+ Add Members
+ setSearch(e.target.value)} />
+
+
+ {selected.length > 0 && (
+
+ {selected.map(u => (
+
+ {u.display_name || u.name}
+ toggle(u)}>ร
+
+ ))}
+
+ )}
+
+
+ >
+ )}
+
+
+ Cancel
+
+ {loading ? 'Creating...' : 'Create'}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx
new file mode 100644
index 0000000..fb8b4ec
--- /dev/null
+++ b/frontend/src/components/ProfileModal.jsx
@@ -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 (
+ e.target === e.currentTarget && onClose()}>
+
+
+
My Profile
+
+
+
+
+
+ {/* Avatar */}
+
+
+
+
{user?.display_name || user?.name}
+
{user?.email}
+
{user?.role}
+
+
+
+ {/* Tabs */}
+
+ setTab('profile')}>Profile
+ setTab('password')}>Change Password
+
+
+ {tab === 'profile' && (
+
+
+ Display Name
+ setDisplayName(e.target.value)} placeholder={user?.name} />
+
+
+ About Me
+
+ {user?.role === 'admin' && (
+
+ setHideAdminTag(e.target.checked)}
+ style={{ accentColor: 'var(--primary)', width: 16, height: 16 }}
+ />
+ Hide "Admin" tag next to my name in messages
+
+ )}
+
+ {loading ? 'Saving...' : 'Save Changes'}
+
+
+ )}
+
+ {tab === 'password' && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx
new file mode 100644
index 0000000..e1761ac
--- /dev/null
+++ b/frontend/src/components/SettingsModal.jsx
@@ -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 (
+
+
+ {currentUrl ? (
+
+ ) : (
+
{defaultSvg}
+ )}
+
+
+
{label}
+
+ Upload PNG
+
+
+ {currentUrl && (
+
Custom icon active
+ )}
+
+
+ );
+}
+
+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 = (
+
+
+
+
+ );
+ const groupInfoSvg = (
+
+
+
+ );
+
+ return (
+ e.target === e.currentTarget && onClose()}>
+
+
+
App Settings
+
+
+
+
+
+ {/* App Logo */}
+
+
App Logo
+
+
+ {settings.logo_url ? (
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+
+ Upload Logo
+
+
+
Square format, max 1MB. Used in sidebar, login page and browser tab.
+
+
+
+
+ {/* App Name */}
+
+
App Name
+
+ setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
+
+ {loading ? '...' : 'Save'}
+
+
+
+
+ {/* Custom Icons */}
+
+
+ {/* Reset + Version */}
+
+
Reset
+
+ {!showResetConfirm ? (
+
setShowResetConfirm(true)}>
+ Reset All to Defaults
+
+ ) : (
+
+
+ This will reset the app name, logo, and all custom icons to their install defaults. This cannot be undone.
+
+
+
+ {resetting ? 'Resetting...' : 'Yes, Reset Everything'}
+
+ setShowResetConfirm(false)}>
+ Cancel
+
+
+
+ )}
+ {settings.app_version && (
+
+ v{settings.app_version}
+
+ )}
+
{/* end flex row */}
+
{/* end Reset section */}
+
+ {settings.pw_reset_active === 'true' && (
+
+ โ ๏ธ
+ PW_RESET is active. The default admin password is being reset on every restart. Set PW_RESET=false in your environment variables to stop this.
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/Sidebar.css b/frontend/src/components/Sidebar.css
new file mode 100644
index 0000000..ab9d016
--- /dev/null
+++ b/frontend/src/components/Sidebar.css
@@ -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;
+}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
new file mode 100644
index 0000000..ad0d587
--- /dev/null
+++ b/frontend/src/components/Sidebar.jsx
@@ -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
+ 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 (
+ onSelectGroup(group.id)}>
+
+ {group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
+
+
+
+ {group.name}
+ {group.last_message_at && (
+ {formatTime(group.last_message_at)}
+ )}
+
+
+
+ {group.last_message || (group.is_readonly ? '๐ข Read-only' : 'No messages yet')}
+
+ {notifs > 0 && {notifs} }
+ {hasUnread && notifs === 0 && }
+
+
+
+ );
+ };
+
+ return (
+
+ {/* Header with live app name and logo */}
+
+
+ {logoUrl ? (
+
+ ) : (
+
+ )}
+
{appName}
+ {!connected &&
}
+
+
+ {settings.icon_newchat ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {/* Search */}
+
+
+
+
+
+ setSearch(e.target.value)} />
+
+
+
+ {/* Groups list */}
+
+ {publicFiltered.length > 0 && (
+
+
CHANNELS
+ {publicFiltered.map(g =>
)}
+
+ )}
+
+ {privateFiltered.length > 0 && (
+
+
DIRECT MESSAGES
+ {privateFiltered.map(g =>
)}
+
+ )}
+
+ {filtered.length === 0 && (
+
+ No chats found
+
+ )}
+
+
+ {/* User footer */}
+
+
setShowMenu(!showMenu)}>
+
+
+ {user?.display_name || user?.name}
+ {user?.role}
+
+
+
+
+
+
+ {showMenu && (
+
setShowMenu(false)}>
+
+
+ Profile
+
+ {user?.role === 'admin' && (
+ <>
+
+
+ User Manager
+
+
+
+ Settings
+
+ >
+ )}
+
+
+
+ Sign out
+
+
+ )}
+
+
+ );
+}
+
+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' });
+}
diff --git a/frontend/src/components/SupportModal.jsx b/frontend/src/components/SupportModal.jsx
new file mode 100644
index 0000000..4ffe290
--- /dev/null
+++ b/frontend/src/components/SupportModal.jsx
@@ -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 (
+ e.target === e.currentTarget && onClose()}>
+
+ {sent ? (
+ /* Success state */
+
+
+
Message Sent
+
+ Your message has been received. An administrator will follow up with you shortly.
+
+
+ Close
+
+
+ ) : (
+ /* Form state */
+ <>
+
+
Contact Support
+
+
+
+
+
+
+
+ Fill out the form below and an administrator will get back to you.
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/UserManagerModal.jsx b/frontend/src/components/UserManagerModal.jsx
new file mode 100644
index 0000000..50c5041
--- /dev/null
+++ b/frontend/src/components/UserManagerModal.jsx
@@ -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 (
+ e.target === e.currentTarget && onClose()}>
+
+
+
User Manager
+
+
+
+
+
+ {/* Tabs */}
+
+ setTab('users')}>
+ All Users ({users.length})
+
+ setTab('create')}>
+ + Create User
+
+ setTab('bulk')}>
+ Bulk Import CSV
+
+
+
+ {/* Users list */}
+ {tab === 'users' && (
+ <>
+
setSearch(e.target.value)} />
+ {loading ? (
+
+ ) : (
+
+ {filtered.map(u => (
+
+
+
+
+
+ {u.display_name || u.name}
+ {u.role}
+ {u.status !== 'active' && {u.status} }
+ {u.is_default_admin ? Default Admin : null}
+
+
{u.email}
+ {u.must_change_password ?
โ Must change password : null}
+
+
+ {/* Actions */}
+ {!u.is_default_admin && (
+
+ {resetingId === u.id ? (
+
+ setResetPw(e.target.value)} />
+ handleResetPw(u.id)}>Set
+ { setResetingId(null); setResetPw(''); }}>โ
+
+ ) : (
+ <>
+
setResetingId(u.id)} title="Reset password">
+
+ Reset PW
+
+
handleRole(u, e.target.value)}
+ className="input"
+ style={{ width: 90, padding: '4px 6px', fontSize: 12 }}
+ >
+ Member
+ Admin
+
+ {u.status === 'active' ? (
+
handleSuspend(u)}>Suspend
+ ) : u.status === 'suspended' ? (
+
handleActivate(u)} style={{ color: 'var(--success)' }}>Activate
+ ) : null}
+
handleDelete(u)}>Delete
+ >
+ )}
+
+ )}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {/* Create user */}
+ {tab === 'create' && (
+
+
+
+
+ Temp Password
+ setForm(p => ({ ...p, password: e.target.value }))} />
+
+
+ Role
+ setForm(p => ({ ...p, role: e.target.value }))}>
+ Member
+ Admin
+
+
+
+
User will be required to change their password on first login.
+
{creating ? 'Creating...' : 'Create User'}
+
+ )}
+
+ {/* Bulk import */}
+ {tab === 'bulk' && (
+
+
+
CSV Format
+
+ name,email,password,role{'\n'}
+ John Doe,john@example.com,TempPass123,member
+
+
+ role can be "member" or "admin". Password defaults to TempPass@123 if omitted. All users must change password on first login.
+
+
+
+
+ Select CSV File
+
+
+
+ {bulkPreview.length > 0 && (
+ <>
+
+
Preview ({bulkPreview.length} users)
+
+ {bulkPreview.slice(0, 10).map((u, i) => (
+
+ {u.name}
+ {u.email}
+ {u.role}
+
+ ))}
+ {bulkPreview.length > 10 && (
+
+ ...and {bulkPreview.length - 10} more
+
+ )}
+
+
+
+ {bulkLoading ? 'Importing...' : `Import ${bulkPreview.length} Users`}
+
+ >
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/UserProfilePopup.jsx b/frontend/src/components/UserProfilePopup.jsx
new file mode 100644
index 0000000..942ba9b
--- /dev/null
+++ b/frontend/src/components/UserProfilePopup.jsx
@@ -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 (
+
+
+
+
+ {user.display_name || user.name}
+
+ {user.role === 'admin' && !user.hide_admin_tag && (
+
Admin
+ )}
+
+ {user.about_me && (
+
+ {user.about_me}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx
new file mode 100644
index 0000000..8518ceb
--- /dev/null
+++ b/frontend/src/contexts/AuthContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export const useAuth = () => useContext(AuthContext);
diff --git a/frontend/src/contexts/SocketContext.jsx b/frontend/src/contexts/SocketContext.jsx
new file mode 100644
index 0000000..a33fac8
--- /dev/null
+++ b/frontend/src/contexts/SocketContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export const useSocket = () => useContext(SocketContext);
diff --git a/frontend/src/contexts/ToastContext.jsx b/frontend/src/contexts/ToastContext.jsx
new file mode 100644
index 0000000..575ecec
--- /dev/null
+++ b/frontend/src/contexts/ToastContext.jsx
@@ -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 (
+
+ {children}
+
+ {toasts.map(t => (
+
{t.msg}
+ ))}
+
+
+ );
+}
+
+export const useToast = () => useContext(ToastContext);
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..df71973
--- /dev/null
+++ b/frontend/src/index.css
@@ -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;
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..0a37a6c
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -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(
+
+
+
+);
diff --git a/frontend/src/pages/ChangePassword.jsx b/frontend/src/pages/ChangePassword.jsx
new file mode 100644
index 0000000..d3947c3
--- /dev/null
+++ b/frontend/src/pages/ChangePassword.jsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/pages/Chat.css b/frontend/src/pages/Chat.css
new file mode 100644
index 0000000..5b8dac0
--- /dev/null
+++ b/frontend/src/pages/Chat.css
@@ -0,0 +1,12 @@
+.chat-layout {
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+ background: var(--background);
+}
+
+@media (max-width: 767px) {
+ .chat-layout {
+ position: relative;
+ }
+}
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx
new file mode 100644
index 0000000..b61c90c
--- /dev/null
+++ b/frontend/src/pages/Chat.jsx
@@ -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 (
+
+ {(!isMobile || showSidebar) && (
+
setModal('newchat')}
+ onProfile={() => setModal('profile')}
+ onUsers={() => setModal('users')}
+ onSettings={() => setModal('settings')}
+ onGroupsUpdated={loadGroups}
+ />
+ )}
+
+ {(!isMobile || !showSidebar) && (
+ setShowSidebar(true) : null}
+ onGroupUpdated={loadGroups}
+ />
+ )}
+
+ {modal === 'profile' && setModal(null)} />}
+ {modal === 'users' && setModal(null)} />}
+ {modal === 'settings' && setModal(null)} />}
+ {modal === 'newchat' && setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
+
+ );
+}
diff --git a/frontend/src/pages/Login.css b/frontend/src/pages/Login.css
new file mode 100644
index 0000000..0c2f5d2
--- /dev/null
+++ b/frontend/src/pages/Login.css
@@ -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);
+}
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
new file mode 100644
index 0000000..6d3a53c
--- /dev/null
+++ b/frontend/src/pages/Login.jsx
@@ -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 (
+
+
+
+ {logoUrl ? (
+
+ ) : (
+
+ )}
+
{appName}
+
Sign in to continue
+
+
+ {settings.pw_reset_active === 'true' && (
+
+ โ ๏ธ
+ PW_RESET is enabled. The admin password is being reset on each restart. Disable PW_RESET in your environment to stop this behavior.
+
+ )}
+
+
+
+ Email
+ setEmail(e.target.value)} required autoFocus placeholder="your@email.com" />
+
+
+ Password
+ setPassword(e.target.value)} required placeholder="โขโขโขโขโขโขโขโข" />
+
+
+
+ setRememberMe(e.target.checked)} />
+ Remember me
+
+
+
+ {loading ? : 'Sign in'}
+
+
+
+
+ setShowSupport(true)}>
+ Need help? Contact Support
+
+
+
+ {showSupport &&
setShowSupport(false)} />}
+
+
+ );
+}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
new file mode 100644
index 0000000..f14ad70
--- /dev/null
+++ b/frontend/src/utils/api.js
@@ -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)}`),
+};
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..b7b5359
--- /dev/null
+++ b/frontend/vite.config.js
@@ -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 }
+ }
+ }
+});