Initial Commit
This commit is contained in:
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#1a73e8" />
|
||||||
|
<meta name="description" content="TeamChat - Modern team messaging" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<title>TeamChat</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "teamchat-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.22.0",
|
||||||
|
"socket.io-client": "^4.6.1",
|
||||||
|
"emoji-mart": "^5.5.2",
|
||||||
|
"@emoji-mart/data": "^1.1.2",
|
||||||
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
"papaparse": "^5.4.1",
|
||||||
|
"date-fns": "^3.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"vite": "^5.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1021 B |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
37
frontend/public/manifest.json
Normal file
37
frontend/public/manifest.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "TeamChat",
|
||||||
|
"short_name": "TeamChat",
|
||||||
|
"description": "Modern team messaging application",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#1a73e8",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
80
frontend/public/sw.js
Normal file
80
frontend/public/sw.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
const CACHE_NAME = 'teamchat-v2';
|
||||||
|
const STATIC_ASSETS = ['/'];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = event.request.url;
|
||||||
|
if (url.includes('/api/') || url.includes('/socket.io/') || url.includes('/manifest.json')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track badge count in SW
|
||||||
|
let badgeCount = 0;
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
const data = event.data.json();
|
||||||
|
|
||||||
|
badgeCount++;
|
||||||
|
|
||||||
|
// Update app badge (supported on Android Chrome and some desktop)
|
||||||
|
if (navigator.setAppBadge) {
|
||||||
|
navigator.setAppBadge(badgeCount).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || 'New Message', {
|
||||||
|
body: data.body || '',
|
||||||
|
icon: '/icons/icon-192.png',
|
||||||
|
badge: '/icons/icon-192.png',
|
||||||
|
data: { url: data.url || '/' },
|
||||||
|
tag: 'teamchat-message', // replaces previous notification instead of stacking
|
||||||
|
renotify: true, // still vibrate/sound even if replacing
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
badgeCount = 0;
|
||||||
|
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
const url = event.notification.data?.url || '/';
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
client.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear badge when user opens the app
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'CLEAR_BADGE') {
|
||||||
|
badgeCount = 0;
|
||||||
|
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
45
frontend/src/App.jsx
Normal file
45
frontend/src/App.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext.jsx';
|
||||||
|
import { SocketProvider } from './contexts/SocketContext.jsx';
|
||||||
|
import { ToastProvider } from './contexts/ToastContext.jsx';
|
||||||
|
import Login from './pages/Login.jsx';
|
||||||
|
import Chat from './pages/Chat.jsx';
|
||||||
|
import ChangePassword from './pages/ChangePassword.jsx';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }) {
|
||||||
|
const { user, loading, mustChangePassword } = useAuth();
|
||||||
|
if (loading) return (
|
||||||
|
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div className="spinner" style={{ width: 36, height: 36 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (mustChangePassword) return <Navigate to="/change-password" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthRoute({ children }) {
|
||||||
|
const { user, loading, mustChangePassword } = useAuth();
|
||||||
|
if (loading) return null;
|
||||||
|
if (user && !mustChangePassword) return <Navigate to="/" replace />;
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<SocketProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<AuthRoute><Login /></AuthRoute>} />
|
||||||
|
<Route path="/change-password" element={<ChangePassword />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><Chat /></ProtectedRoute>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</SocketProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/src/components/Avatar.jsx
Normal file
24
frontend/src/components/Avatar.jsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export default function Avatar({ user, size = 'md', className = '' }) {
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const initials = (() => {
|
||||||
|
const name = user.display_name || user.name || '';
|
||||||
|
const parts = name.trim().split(' ').filter(Boolean);
|
||||||
|
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
return '??';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const colors = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
|
||||||
|
const colorIdx = (user.name || '').charCodeAt(0) % colors.length;
|
||||||
|
const bg = colors[colorIdx];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`avatar avatar-${size} ${className}`} style={{ background: user.avatar ? undefined : bg }}>
|
||||||
|
{user.avatar
|
||||||
|
? <img src={user.avatar} alt={initials} />
|
||||||
|
: initials
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
frontend/src/components/ChatWindow.css
Normal file
142
frontend/src/components/ChatWindow.css
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
.chat-window {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface-variant);
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-window.empty {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p { font-size: 14px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-height: 64px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-icon-sm {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #e65100;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn {
|
||||||
|
align-self: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--primary);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--primary-light);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.load-more-btn:hover { background: #d2e3fc; }
|
||||||
|
|
||||||
|
/* Typing indicator */
|
||||||
|
.typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots span {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
animation: bounce 1.2s infinite;
|
||||||
|
}
|
||||||
|
.dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); }
|
||||||
|
30% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Readonly bar */
|
||||||
|
.readonly-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
252
frontend/src/components/ChatWindow.jsx
Normal file
252
frontend/src/components/ChatWindow.jsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import Message from './Message.jsx';
|
||||||
|
import MessageInput from './MessageInput.jsx';
|
||||||
|
import GroupInfoModal from './GroupInfoModal.jsx';
|
||||||
|
import './ChatWindow.css';
|
||||||
|
|
||||||
|
export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||||
|
const { socket } = useSocket();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [replyTo, setReplyTo] = useState(null);
|
||||||
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
||||||
|
const [typing, setTyping] = useState([]);
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
const messagesTopRef = useRef(null);
|
||||||
|
const typingTimers = useRef({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSettings().then(({ settings }) => {
|
||||||
|
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||||
|
}).catch(() => {});
|
||||||
|
const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
|
||||||
|
window.addEventListener('teamchat:settings-changed', handler);
|
||||||
|
return () => window.removeEventListener('teamchat:settings-changed', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((smooth = false) => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!group) { setMessages([]); return; }
|
||||||
|
setMessages([]);
|
||||||
|
setHasMore(false);
|
||||||
|
setLoading(true);
|
||||||
|
api.getMessages(group.id)
|
||||||
|
.then(({ messages }) => {
|
||||||
|
setMessages(messages);
|
||||||
|
setHasMore(messages.length >= 50);
|
||||||
|
setTimeout(() => scrollToBottom(), 50);
|
||||||
|
})
|
||||||
|
.catch(e => toast(e.message, 'error'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [group?.id]);
|
||||||
|
|
||||||
|
// Socket events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket || !group) return;
|
||||||
|
|
||||||
|
const handleNew = (msg) => {
|
||||||
|
if (msg.group_id !== group.id) return;
|
||||||
|
setMessages(prev => {
|
||||||
|
if (prev.find(m => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
setTimeout(() => scrollToBottom(true), 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleted = ({ messageId }) => {
|
||||||
|
setMessages(prev => prev.filter(m => m.id !== messageId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReaction = ({ messageId, reactions }) => {
|
||||||
|
setMessages(prev => prev.map(m => m.id === messageId ? { ...m, reactions } : m));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypingStart = ({ userId: tid, user: tu }) => {
|
||||||
|
if (tid === user.id) return;
|
||||||
|
setTyping(prev => prev.find(t => t.userId === tid) ? prev : [...prev, { userId: tid, name: tu?.display_name || tu?.name || 'Someone' }]);
|
||||||
|
if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]);
|
||||||
|
typingTimers.current[tid] = setTimeout(() => {
|
||||||
|
setTyping(prev => prev.filter(t => t.userId !== tid));
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypingStop = ({ userId: tid }) => {
|
||||||
|
setTyping(prev => prev.filter(t => t.userId !== tid));
|
||||||
|
if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('message:new', handleNew);
|
||||||
|
socket.on('message:deleted', handleDeleted);
|
||||||
|
socket.on('reaction:updated', handleReaction);
|
||||||
|
socket.on('typing:start', handleTypingStart);
|
||||||
|
socket.on('typing:stop', handleTypingStop);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('message:new', handleNew);
|
||||||
|
socket.off('message:deleted', handleDeleted);
|
||||||
|
socket.off('reaction:updated', handleReaction);
|
||||||
|
socket.off('typing:start', handleTypingStart);
|
||||||
|
socket.off('typing:stop', handleTypingStop);
|
||||||
|
};
|
||||||
|
}, [socket, group?.id, user.id]);
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (!messages.length) return;
|
||||||
|
const oldest = messages[0];
|
||||||
|
const { messages: older } = await api.getMessages(group.id, oldest.id);
|
||||||
|
setMessages(prev => [...older, ...prev]);
|
||||||
|
setHasMore(older.length >= 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async ({ content, imageFile, linkPreview }) => {
|
||||||
|
if (!group) return;
|
||||||
|
const replyId = replyTo?.id;
|
||||||
|
setReplyTo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (imageFile) {
|
||||||
|
const { message } = await api.uploadImage(group.id, imageFile, { replyToId: replyId, content });
|
||||||
|
// Add immediately to local state — don't wait for socket (it may be slow for large files)
|
||||||
|
if (message) {
|
||||||
|
setMessages(prev => prev.find(m => m.id === message.id) ? prev : [...prev, message]);
|
||||||
|
setTimeout(() => scrollToBottom(true), 50);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
socket?.emit('message:send', {
|
||||||
|
groupId: group.id, content, replyToId: replyId, linkPreview
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return (
|
||||||
|
<div className="chat-window empty">
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" width="64" height="64">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3>Select a conversation</h3>
|
||||||
|
<p>Choose from your existing chats or start a new one</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-window">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="chat-header">
|
||||||
|
{onBack && (
|
||||||
|
<button className="btn-icon" onClick={onBack}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="group-icon-sm"
|
||||||
|
style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}
|
||||||
|
>
|
||||||
|
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-col flex-1 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="chat-header-name">{group.name}</span>
|
||||||
|
{group.is_readonly ? (
|
||||||
|
<span className="readonly-badge">Read-only</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="chat-header-sub">
|
||||||
|
{group.type === 'public' ? 'Public channel' : 'Private group'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info">
|
||||||
|
{iconGroupInfo ? (
|
||||||
|
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="24" height="24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="messages-container" ref={messagesTopRef}>
|
||||||
|
{hasMore && (
|
||||||
|
<button className="load-more-btn" onClick={loadMore}>Load older messages</button>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<Message
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
prevMessage={messages[i - 1]}
|
||||||
|
currentUser={user}
|
||||||
|
onReply={(m) => setReplyTo(m)}
|
||||||
|
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
|
||||||
|
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{typing.length > 0 && (
|
||||||
|
<div className="typing-indicator">
|
||||||
|
<span>{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing</span>
|
||||||
|
<span className="dots"><span/><span/><span/></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
{(!group.is_readonly || user.role === 'admin') ? (
|
||||||
|
<MessageInput
|
||||||
|
group={group}
|
||||||
|
replyTo={replyTo}
|
||||||
|
onCancelReply={() => setReplyTo(null)}
|
||||||
|
onSend={handleSend}
|
||||||
|
onTyping={(isTyping) => {
|
||||||
|
if (socket) {
|
||||||
|
if (isTyping) socket.emit('typing:start', { groupId: group.id });
|
||||||
|
else socket.emit('typing:stop', { groupId: group.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="readonly-bar">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
|
This channel is read-only
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showInfo && (
|
||||||
|
<GroupInfoModal
|
||||||
|
group={group}
|
||||||
|
onClose={() => setShowInfo(false)}
|
||||||
|
onUpdated={onGroupUpdated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
frontend/src/components/GroupInfoModal.jsx
Normal file
171
frontend/src/components/GroupInfoModal.jsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import Avatar from './Avatar.jsx';
|
||||||
|
|
||||||
|
export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const [members, setMembers] = useState([]);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [newName, setNewName] = useState(group.name);
|
||||||
|
const [addSearch, setAddSearch] = useState('');
|
||||||
|
const [addResults, setAddResults] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = group.owner_id === user.id;
|
||||||
|
const isAdmin = user.role === 'admin';
|
||||||
|
const canManage = (group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin);
|
||||||
|
const canRename = !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (group.type === 'private') {
|
||||||
|
api.getMembers(group.id).then(({ members }) => setMembers(members)).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [group.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (addSearch) {
|
||||||
|
api.searchUsers(addSearch).then(({ users }) => setAddResults(users)).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [addSearch]);
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (!newName.trim() || newName === group.name) { setEditing(false); return; }
|
||||||
|
try {
|
||||||
|
await api.renameGroup(group.id, newName.trim());
|
||||||
|
toast('Group renamed', 'success');
|
||||||
|
onUpdated();
|
||||||
|
setEditing(false);
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave = async () => {
|
||||||
|
if (!confirm('Leave this group?')) return;
|
||||||
|
try {
|
||||||
|
await api.leaveGroup(group.id);
|
||||||
|
toast('Left group', 'success');
|
||||||
|
onUpdated();
|
||||||
|
onClose();
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTakeOwnership = async () => {
|
||||||
|
if (!confirm('Take ownership of this private group? You will be able to see all messages.')) return;
|
||||||
|
try {
|
||||||
|
await api.takeOwnership(group.id);
|
||||||
|
toast('Ownership taken', 'success');
|
||||||
|
onUpdated();
|
||||||
|
onClose();
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async (u) => {
|
||||||
|
try {
|
||||||
|
await api.addMember(group.id, u.id);
|
||||||
|
toast(`${u.display_name || u.name} added`, 'success');
|
||||||
|
api.getMembers(group.id).then(({ members }) => setMembers(members));
|
||||||
|
setAddSearch('');
|
||||||
|
setAddResults([]);
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm('Delete this group? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await api.deleteGroup(group.id);
|
||||||
|
toast('Group deleted', 'success');
|
||||||
|
onUpdated();
|
||||||
|
onClose();
|
||||||
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>Group Info</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className="input flex-1" value={newName} onChange={e => setNewName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleRename()} />
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleRename}>Save</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-8" style={{ gap: 12 }}>
|
||||||
|
<h3 style={{ fontSize: 18, fontWeight: 600, flex: 1 }}>{group.name}</h3>
|
||||||
|
{canRename && (
|
||||||
|
<button className="btn-icon" onClick={() => setEditing(true)} title="Rename">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-6" style={{ gap: 8, marginTop: 4 }}>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{group.type === 'public' ? 'Public channel' : 'Private group'}
|
||||||
|
</span>
|
||||||
|
{group.is_readonly && <span className="readonly-badge" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: '#fff3e0', color: '#e65100' }}>Read-only</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members (private groups) */}
|
||||||
|
{group.type === 'private' && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
|
Members ({members.length})
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{members.map(m => (
|
||||||
|
<div key={m.id} className="flex items-center gap-2" style={{ gap: 10, padding: '6px 0' }}>
|
||||||
|
<Avatar user={m} size="sm" />
|
||||||
|
<span className="flex-1 text-sm">{m.display_name || m.name}</span>
|
||||||
|
{m.id === group.owner_id && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<input className="input" placeholder="Search to add member..." value={addSearch} onChange={e => setAddSearch(e.target.value)} />
|
||||||
|
{addResults.length > 0 && addSearch && (
|
||||||
|
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', marginTop: 4, maxHeight: 150, overflowY: 'auto' }}>
|
||||||
|
{addResults.filter(u => !members.find(m => m.id === u.id)).map(u => (
|
||||||
|
<button key={u.id} className="flex items-center gap-2 w-full" style={{ gap: 10, padding: '8px 12px', textAlign: 'left', transition: 'background var(--transition)' }} onClick={() => handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
|
||||||
|
<Avatar user={u} size="sm" />
|
||||||
|
<span className="text-sm flex-1">{u.display_name || u.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex-col gap-2">
|
||||||
|
{group.type === 'private' && group.owner_id !== user.id && (
|
||||||
|
<button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Group</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && group.type === 'private' && group.owner_id !== user.id && (
|
||||||
|
<button className="btn btn-secondary w-full" onClick={handleTakeOwnership}>
|
||||||
|
Take Ownership (Admin)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(isOwner || (isAdmin && group.type === 'public')) && !group.is_default && (
|
||||||
|
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete Group</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/components/ImageLightbox.jsx
Normal file
78
frontend/src/components/ImageLightbox.jsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export default function ImageLightbox({ src, onClose }) {
|
||||||
|
const overlayRef = useRef(null);
|
||||||
|
const imgRef = useRef(null);
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={(e) => e.target === overlayRef.current && onClose()}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 2000,
|
||||||
|
background: 'rgba(0,0,0,0.92)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
touchAction: 'pinch-zoom',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 16, right: 16,
|
||||||
|
background: 'rgba(255,255,255,0.15)', border: 'none',
|
||||||
|
borderRadius: '50%', width: 40, height: 40,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', color: 'white', zIndex: 2001,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Download button */}
|
||||||
|
<a
|
||||||
|
href={src}
|
||||||
|
download
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 16, right: 64,
|
||||||
|
background: 'rgba(255,255,255,0.15)', border: 'none',
|
||||||
|
borderRadius: '50%', width: 40, height: 40,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', color: 'white', zIndex: 2001, textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Image — fit to screen, browser handles pinch-zoom natively */}
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={src}
|
||||||
|
alt="Full size"
|
||||||
|
style={{
|
||||||
|
maxWidth: '95vw',
|
||||||
|
maxHeight: '95vh',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 8,
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'pinch-zoom',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
frontend/src/components/Message.css
Normal file
266
frontend/src/components/Message.css
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
.date-separator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-separator span {
|
||||||
|
background: rgba(0,0,0,0.06);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 1px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-wrapper.own { flex-direction: row-reverse; }
|
||||||
|
.message-wrapper.grouped { margin-top: 1px; }
|
||||||
|
.message-wrapper:not(.grouped) { margin-top: 8px; }
|
||||||
|
|
||||||
|
.msg-avatar { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 65%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.own .message-body { align-items: flex-end; }
|
||||||
|
|
||||||
|
.msg-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reply preview */
|
||||||
|
.reply-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: -4px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-bar { width: 3px; background: var(--primary); border-radius: 2px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.reply-name { font-size: 11px; font-weight: 600; color: var(--primary); }
|
||||||
|
.reply-text { font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
|
||||||
|
|
||||||
|
/* Bubble row */
|
||||||
|
.msg-bubble-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.own .msg-bubble-wrap { flex-direction: row-reverse; }
|
||||||
|
|
||||||
|
/* Wrapper that holds the actions toolbar + bubble together */
|
||||||
|
.msg-bubble-with-actions {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions toolbar — floats above the bubble */
|
||||||
|
.msg-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
position: absolute;
|
||||||
|
top: -36px;
|
||||||
|
z-index: 20;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Own messages: toolbar anchors to the right edge of bubble */
|
||||||
|
.msg-actions.actions-left { right: 0; }
|
||||||
|
/* Other messages: toolbar anchors to the left edge of bubble */
|
||||||
|
.msg-actions.actions-right { left: 0; }
|
||||||
|
|
||||||
|
.quick-emoji {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.quick-emoji:hover { background: var(--background); transform: scale(1.2); }
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.action-btn:hover { color: var(--text-primary); }
|
||||||
|
.action-btn.danger:hover { color: var(--error); }
|
||||||
|
|
||||||
|
/* Emoji picker — anchored relative to the toolbar */
|
||||||
|
.emoji-picker-wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: -360px; /* above the toolbar by default */
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.emoji-picker-wrap.picker-right { left: 0; }
|
||||||
|
.emoji-picker-wrap.picker-left { right: 0; }
|
||||||
|
/* When message is near top of window, open picker downward instead */
|
||||||
|
.emoji-picker-wrap.picker-down {
|
||||||
|
top: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bubble */
|
||||||
|
.msg-bubble {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 18px;
|
||||||
|
max-width: 100%;
|
||||||
|
word-break: break-word;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-bubble.out {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-bubble.in {
|
||||||
|
background: white;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-bubble.deleted {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-text { font-size: 13px; color: var(--text-tertiary); font-style: italic; }
|
||||||
|
|
||||||
|
.msg-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
color: #1a5ca8;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(26,92,168,0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.out .mention { color: #afd0ff; background: rgba(255,255,255,0.15); }
|
||||||
|
|
||||||
|
.msg-image {
|
||||||
|
max-width: 240px;
|
||||||
|
max-height: 240px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reactions */
|
||||||
|
.reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.reaction-count { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.reaction-btn.active { background: var(--primary-light); border-color: var(--primary); }
|
||||||
|
.reaction-btn.active .reaction-count { color: var(--primary); }
|
||||||
|
.reaction-btn:hover { background: var(--primary-light); }
|
||||||
|
.reaction-remove {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-left: 1px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.reaction-btn:hover .reaction-remove { opacity: 1; }
|
||||||
|
|
||||||
|
/* Link preview */
|
||||||
|
.link-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(0,0,0,0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
max-width: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.link-preview:hover { background: rgba(0,0,0,0.1); }
|
||||||
|
|
||||||
|
.link-preview-img {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-preview-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-site { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.link-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
|
.link-desc { font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.out .link-preview { background: rgba(255,255,255,0.15); }
|
||||||
|
.out .link-title { color: white; }
|
||||||
|
.out .link-desc { color: rgba(255,255,255,0.8); }
|
||||||
249
frontend/src/components/Message.jsx
Normal file
249
frontend/src/components/Message.jsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import Avatar from './Avatar.jsx';
|
||||||
|
import UserProfilePopup from './UserProfilePopup.jsx';
|
||||||
|
import ImageLightbox from './ImageLightbox.jsx';
|
||||||
|
import Picker from '@emoji-mart/react';
|
||||||
|
import data from '@emoji-mart/data';
|
||||||
|
import './Message.css';
|
||||||
|
|
||||||
|
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
||||||
|
|
||||||
|
function formatMsgContent(content) {
|
||||||
|
if (!content) return '';
|
||||||
|
return content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `<span class="mention">@${name}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact }) {
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
|
const wrapperRef = useRef(null);
|
||||||
|
const pickerRef = useRef(null);
|
||||||
|
const avatarRef = useRef(null);
|
||||||
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
|
const [lightboxSrc, setLightboxSrc] = useState(null);
|
||||||
|
const [pickerOpensDown, setPickerOpensDown] = useState(false);
|
||||||
|
|
||||||
|
const isOwn = msg.user_id === currentUser.id;
|
||||||
|
const isDeleted = !!msg.is_deleted;
|
||||||
|
|
||||||
|
// Deleted messages are filtered out by ChatWindow, but guard here too
|
||||||
|
if (isDeleted) return null;
|
||||||
|
|
||||||
|
const canDelete = (
|
||||||
|
msg.user_id === currentUser.id ||
|
||||||
|
(currentUser.role === 'admin' && msg.group_type !== 'private') ||
|
||||||
|
(msg.group_owner_id === currentUser.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevSameUser = prevMessage && prevMessage.user_id === msg.user_id &&
|
||||||
|
new Date(msg.created_at) - new Date(prevMessage.created_at) < 60000;
|
||||||
|
|
||||||
|
const showDateSep = !prevMessage ||
|
||||||
|
new Date(msg.created_at).toDateString() !== new Date(prevMessage.created_at).toDateString();
|
||||||
|
|
||||||
|
const reactionMap = {};
|
||||||
|
for (const r of (msg.reactions || [])) {
|
||||||
|
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, users: [], hasMe: false };
|
||||||
|
reactionMap[r.emoji].count++;
|
||||||
|
reactionMap[r.emoji].users.push(r.user_name);
|
||||||
|
if (r.user_id === currentUser.id) reactionMap[r.emoji].hasMe = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close emoji picker when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEmojiPicker) return;
|
||||||
|
const handler = (e) => {
|
||||||
|
if (pickerRef.current && !pickerRef.current.contains(e.target)) {
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [showEmojiPicker]);
|
||||||
|
|
||||||
|
const handleReact = (emoji) => {
|
||||||
|
onReact(msg.id, emoji);
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePicker = () => {
|
||||||
|
if (!showEmojiPicker && wrapperRef.current) {
|
||||||
|
// If the message is in the top 400px of viewport, open picker downward
|
||||||
|
const rect = wrapperRef.current.getBoundingClientRect();
|
||||||
|
setPickerOpensDown(rect.top < 400);
|
||||||
|
}
|
||||||
|
setShowEmojiPicker(p => !p);
|
||||||
|
};
|
||||||
|
|
||||||
|
const msgUser = {
|
||||||
|
id: msg.user_id,
|
||||||
|
name: msg.user_name,
|
||||||
|
display_name: msg.user_display_name,
|
||||||
|
avatar: msg.user_avatar,
|
||||||
|
role: msg.user_role,
|
||||||
|
status: msg.user_status,
|
||||||
|
hide_admin_tag: msg.user_hide_admin_tag,
|
||||||
|
about_me: msg.user_about_me,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showDateSep && (
|
||||||
|
<div className="date-separator">
|
||||||
|
<span>{formatDate(msg.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`}
|
||||||
|
onMouseEnter={() => setShowActions(true)}
|
||||||
|
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
|
||||||
|
>
|
||||||
|
{!isOwn && !prevSameUser && (
|
||||||
|
<div ref={avatarRef} style={{ cursor: 'pointer' }} onClick={() => setShowProfile(p => !p)}>
|
||||||
|
<Avatar user={msgUser} size="sm" className="msg-avatar" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isOwn && prevSameUser && <div style={{ width: 32, flexShrink: 0 }} />}
|
||||||
|
|
||||||
|
<div className="message-body">
|
||||||
|
{!isOwn && !prevSameUser && (
|
||||||
|
<div className="msg-name">
|
||||||
|
{msgUser.display_name || msgUser.name}
|
||||||
|
{msgUser.role === 'admin' && !msgUser.hide_admin_tag && <span className="role-badge role-admin" style={{ marginLeft: 6 }}>Admin</span>}
|
||||||
|
{msgUser.status !== 'active' && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--text-tertiary)' }}>(inactive)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reply preview */}
|
||||||
|
{msg.reply_to_id && (
|
||||||
|
<div className="reply-preview">
|
||||||
|
<div className="reply-bar" />
|
||||||
|
<div>
|
||||||
|
<div className="reply-name">{msg.reply_user_display_name || msg.reply_user_name}</div>
|
||||||
|
<div className="reply-text">
|
||||||
|
{msg.reply_is_deleted ? <em style={{ color: 'var(--text-tertiary)' }}>Deleted message</em>
|
||||||
|
: msg.reply_image_url ? '📷 Image'
|
||||||
|
: msg.reply_content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bubble + actions together so actions hover above bubble */}
|
||||||
|
<div className="msg-bubble-wrap">
|
||||||
|
<div className="msg-bubble-with-actions">
|
||||||
|
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
|
||||||
|
{!isDeleted && (showActions || showEmojiPicker) && (
|
||||||
|
<div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}>
|
||||||
|
{QUICK_EMOJIS.map(e => (
|
||||||
|
<button key={e} className="quick-emoji" onClick={() => handleReact(e)} title={e}>{e}</button>
|
||||||
|
))}
|
||||||
|
<button className="btn-icon action-btn" onClick={handleTogglePicker} title="More reactions">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="btn-icon action-btn" onClick={() => onReply(msg)} title="Reply">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
||||||
|
</button>
|
||||||
|
{canDelete && (
|
||||||
|
<button className="btn-icon action-btn danger" onClick={() => onDelete(msg.id)} title="Delete">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Emoji picker anchored to the toolbar */}
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<div
|
||||||
|
className={`emoji-picker-wrap ${isOwn ? 'picker-left' : 'picker-right'} ${pickerOpensDown ? 'picker-down' : ''}`}
|
||||||
|
ref={pickerRef}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Picker data={data} onEmojiSelect={(e) => handleReact(e.native)} theme="light" previewPosition="none" skinTonePosition="none" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}`}>
|
||||||
|
{msg.image_url && (
|
||||||
|
<img
|
||||||
|
src={msg.image_url}
|
||||||
|
alt="attachment"
|
||||||
|
className="msg-image"
|
||||||
|
onClick={() => setLightboxSrc(msg.image_url)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{msg.content && (
|
||||||
|
<p
|
||||||
|
className="msg-text"
|
||||||
|
dangerouslySetInnerHTML={{ __html: formatMsgContent(msg.content) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{msg.link_preview && <LinkPreview data={msg.link_preview} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="msg-time">{formatTime(msg.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(reactionMap).length > 0 && (
|
||||||
|
<div className="reactions">
|
||||||
|
{Object.entries(reactionMap).map(([emoji, { count, users, hasMe }]) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
className={`reaction-btn ${hasMe ? 'active' : ''}`}
|
||||||
|
onClick={() => onReact(msg.id, emoji)}
|
||||||
|
title={hasMe ? `${users.join(', ')} · Click to remove` : users.join(', ')}
|
||||||
|
>
|
||||||
|
{emoji} <span className="reaction-count">{count}</span>
|
||||||
|
{hasMe && <span className="reaction-remove" title="Remove reaction">×</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showProfile && (
|
||||||
|
<UserProfilePopup
|
||||||
|
user={msgUser}
|
||||||
|
anchorEl={avatarRef.current}
|
||||||
|
onClose={() => setShowProfile(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{lightboxSrc && (
|
||||||
|
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkPreview({ data: raw }) {
|
||||||
|
let d;
|
||||||
|
try { d = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; }
|
||||||
|
if (!d?.title) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={d.url} target="_blank" rel="noopener noreferrer" className="link-preview">
|
||||||
|
{d.image && <img src={d.image} alt="" className="link-preview-img" onError={e => e.target.style.display = 'none'} />}
|
||||||
|
<div className="link-preview-content">
|
||||||
|
{d.siteName && <span className="link-site">{d.siteName}</span>}
|
||||||
|
<span className="link-title">{d.title}</span>
|
||||||
|
{d.description && <span className="link-desc">{d.description}</span>}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr) {
|
||||||
|
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
if (d.toDateString() === now.toDateString()) return 'Today';
|
||||||
|
const yest = new Date(now); yest.setDate(yest.getDate() - 1);
|
||||||
|
if (d.toDateString() === yest.toDateString()) return 'Yesterday';
|
||||||
|
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
168
frontend/src/components/MessageInput.css
Normal file
168
frontend/src/components/MessageInput.css
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
.message-input-area {
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-bar-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-preview-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-preview-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-prev-img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-dropdown {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mention-item:hover, .mention-item.active { background: var(--primary-light); }
|
||||||
|
|
||||||
|
.mention-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-role {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-action {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.input-action:hover { color: var(--primary); }
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
max-height: 120px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface-variant);
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.msg-input:focus { outline: none; border-color: var(--primary); background: white; }
|
||||||
|
.msg-input::placeholder { color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: var(--transition);
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
.send-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.send-btn.active:hover { background: var(--primary-dark); }
|
||||||
|
.send-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
247
frontend/src/components/MessageInput.jsx
Normal file
247
frontend/src/components/MessageInput.jsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import './MessageInput.css';
|
||||||
|
|
||||||
|
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
||||||
|
|
||||||
|
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [imageFile, setImageFile] = useState(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
|
const [mentionSearch, setMentionSearch] = useState('');
|
||||||
|
const [mentionResults, setMentionResults] = useState([]);
|
||||||
|
const [mentionIndex, setMentionIndex] = useState(-1);
|
||||||
|
const [showMention, setShowMention] = useState(false);
|
||||||
|
const [linkPreview, setLinkPreview] = useState(null);
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const typingTimer = useRef(null);
|
||||||
|
const wasTyping = useRef(false);
|
||||||
|
const mentionStart = useRef(-1);
|
||||||
|
const fileInput = useRef(null);
|
||||||
|
|
||||||
|
// Handle typing notification
|
||||||
|
const handleTypingChange = (value) => {
|
||||||
|
if (value && !wasTyping.current) {
|
||||||
|
wasTyping.current = true;
|
||||||
|
onTyping(true);
|
||||||
|
}
|
||||||
|
if (typingTimer.current) clearTimeout(typingTimer.current);
|
||||||
|
typingTimer.current = setTimeout(() => {
|
||||||
|
if (wasTyping.current) {
|
||||||
|
wasTyping.current = false;
|
||||||
|
onTyping(false);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Link preview
|
||||||
|
const fetchPreview = useCallback(async (url) => {
|
||||||
|
setLoadingPreview(true);
|
||||||
|
try {
|
||||||
|
const { preview } = await api.getLinkPreview(url);
|
||||||
|
if (preview) setLinkPreview(preview);
|
||||||
|
} catch {}
|
||||||
|
setLoadingPreview(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setText(val);
|
||||||
|
handleTypingChange(val);
|
||||||
|
|
||||||
|
// Detect @mention
|
||||||
|
const cur = e.target.selectionStart;
|
||||||
|
const lastAt = val.lastIndexOf('@', cur - 1);
|
||||||
|
if (lastAt !== -1) {
|
||||||
|
const between = val.slice(lastAt + 1, cur);
|
||||||
|
if (!between.includes(' ') && !between.includes('\n')) {
|
||||||
|
mentionStart.current = lastAt;
|
||||||
|
setMentionSearch(between);
|
||||||
|
setShowMention(true);
|
||||||
|
api.searchUsers(between).then(({ users }) => {
|
||||||
|
setMentionResults(users);
|
||||||
|
setMentionIndex(0);
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowMention(false);
|
||||||
|
|
||||||
|
// Link preview
|
||||||
|
const urls = val.match(URL_REGEX);
|
||||||
|
if (urls && urls[0] !== linkPreview?.url) {
|
||||||
|
fetchPreview(urls[0]);
|
||||||
|
} else if (!urls) {
|
||||||
|
setLinkPreview(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertMention = (user) => {
|
||||||
|
const before = text.slice(0, mentionStart.current);
|
||||||
|
const after = text.slice(inputRef.current.selectionStart);
|
||||||
|
const mention = `@[${user.display_name || user.name}](${user.id}) `;
|
||||||
|
setText(before + mention + after);
|
||||||
|
setShowMention(false);
|
||||||
|
setMentionResults([]);
|
||||||
|
inputRef.current.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (showMention && mentionResults.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => Math.min(i + 1, mentionResults.length - 1)); return; }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => Math.max(i - 1, 0)); return; }
|
||||||
|
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (mentionIndex >= 0) insertMention(mentionResults[mentionIndex]); return; }
|
||||||
|
if (e.key === 'Escape') { setShowMention(false); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed && !imageFile) return;
|
||||||
|
|
||||||
|
const lp = linkPreview;
|
||||||
|
setText('');
|
||||||
|
setLinkPreview(null);
|
||||||
|
setImageFile(null);
|
||||||
|
setImagePreview(null);
|
||||||
|
wasTyping.current = false;
|
||||||
|
onTyping(false);
|
||||||
|
|
||||||
|
await onSend({ content: trimmed || null, imageFile, linkPreview: lp });
|
||||||
|
};
|
||||||
|
|
||||||
|
const compressImage = (file) => new Promise((resolve) => {
|
||||||
|
const MAX_PX = 1920;
|
||||||
|
const QUALITY = 0.82;
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
let { width, height } = img;
|
||||||
|
if (width <= MAX_PX && height <= MAX_PX) {
|
||||||
|
// Already small enough — still re-encode to strip EXIF and reduce size
|
||||||
|
} else {
|
||||||
|
const ratio = Math.min(MAX_PX / width, MAX_PX / height);
|
||||||
|
width = Math.round(width * ratio);
|
||||||
|
height = Math.round(height * ratio);
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||||
|
canvas.toBlob(blob => {
|
||||||
|
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
|
||||||
|
}, 'image/jpeg', QUALITY);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleImageSelect = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const compressed = await compressImage(file);
|
||||||
|
setImageFile(compressed);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => setImagePreview(e.target.result);
|
||||||
|
reader.readAsDataURL(compressed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayText = (t) => {
|
||||||
|
// Convert @[name](id) to @name for display
|
||||||
|
return t.replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-input-area">
|
||||||
|
{/* Reply preview */}
|
||||||
|
{replyTo && (
|
||||||
|
<div className="reply-bar-input">
|
||||||
|
<div className="reply-indicator">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
|
||||||
|
<span>Replying to <strong>{replyTo.user_display_name || replyTo.user_name}</strong></span>
|
||||||
|
<span className="reply-preview-text">{replyTo.content?.slice(0, 60) || (replyTo.image_url ? '📷 Image' : '')}</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn-icon" onClick={onCancelReply}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image preview */}
|
||||||
|
{imagePreview && (
|
||||||
|
<div className="img-preview-bar">
|
||||||
|
<img src={imagePreview} alt="preview" className="img-preview" />
|
||||||
|
<button className="btn-icon" onClick={() => { setImageFile(null); setImagePreview(null); }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link preview */}
|
||||||
|
{linkPreview && (
|
||||||
|
<div className="link-preview-bar">
|
||||||
|
{linkPreview.image && <img src={linkPreview.image} alt="" className="link-prev-img" onError={e => e.target.style.display='none'} />}
|
||||||
|
<div className="flex-col flex-1 overflow-hidden gap-1">
|
||||||
|
{linkPreview.siteName && <span style={{ fontSize: 11, color: 'var(--text-tertiary)', textTransform: 'uppercase' }}>{linkPreview.siteName}</span>}
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600 }} className="truncate">{linkPreview.title}</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn-icon" onClick={() => setLinkPreview(null)}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mention dropdown */}
|
||||||
|
{showMention && mentionResults.length > 0 && (
|
||||||
|
<div className="mention-dropdown">
|
||||||
|
{mentionResults.map((u, i) => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
className={`mention-item ${i === mentionIndex ? 'active' : ''}`}
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); insertMention(u); }}
|
||||||
|
>
|
||||||
|
<div className="mention-avatar">{(u.display_name || u.name)?.[0]?.toUpperCase()}</div>
|
||||||
|
<span>{u.display_name || u.name}</span>
|
||||||
|
<span className="mention-role">{u.role}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="input-row">
|
||||||
|
<button className="btn-icon input-action" onClick={() => fileInput.current?.click()} title="Attach image">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
|
</button>
|
||||||
|
<input ref={fileInput} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageSelect} />
|
||||||
|
|
||||||
|
<div className="input-wrap">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
className="msg-input"
|
||||||
|
placeholder={`Message ${group?.name || ''}...`}
|
||||||
|
value={text}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={1}
|
||||||
|
style={{ resize: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`send-btn ${(text.trim() || imageFile) ? 'active' : ''}`}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!text.trim() && !imageFile}
|
||||||
|
title="Send"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
frontend/src/components/NewChatModal.jsx
Normal file
124
frontend/src/components/NewChatModal.jsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import Avatar from './Avatar.jsx';
|
||||||
|
|
||||||
|
export default function NewChatModal({ onClose, onCreated }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const [tab, setTab] = useState('private'); // 'private' | 'public'
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [isReadonly, setIsReadonly] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [selected, setSelected] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (search) {
|
||||||
|
api.searchUsers(search).then(({ users }) => setUsers(users)).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!name.trim()) return toast('Name required', 'error');
|
||||||
|
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { group } = await api.createGroup({
|
||||||
|
name: name.trim(),
|
||||||
|
type: tab,
|
||||||
|
memberIds: selected.map(u => u.id),
|
||||||
|
isReadonly: tab === 'public' && isReadonly,
|
||||||
|
});
|
||||||
|
toast(`${tab === 'public' ? 'Channel' : 'Chat'} created!`, 'success');
|
||||||
|
onCreated(group);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (u) => {
|
||||||
|
if (u.id === user.id) return;
|
||||||
|
setSelected(prev => prev.find(p => p.id === u.id) ? prev.filter(p => p.id !== u.id) : [...prev, u]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>Start a Chat</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||||
|
<button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Private Group</button>
|
||||||
|
<button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Channel</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{tab === 'public' ? 'Channel Name' : 'Group Name'}
|
||||||
|
</label>
|
||||||
|
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder={tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team'} autoFocus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'public' && user.role === 'admin' && (
|
||||||
|
<label className="flex items-center gap-2 text-sm" style={{ marginBottom: 16, cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
||||||
|
<input type="checkbox" checked={isReadonly} onChange={e => setIsReadonly(e.target.checked)} />
|
||||||
|
Read-only channel (only admins can post)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'private' && (
|
||||||
|
<>
|
||||||
|
<div className="flex-col gap-2" style={{ marginBottom: 12 }}>
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Add Members</label>
|
||||||
|
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="flex gap-2" style={{ flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
|
{selected.map(u => (
|
||||||
|
<span key={u.id} className="chip">
|
||||||
|
{u.display_name || u.name}
|
||||||
|
<span className="chip-remove" onClick={() => toggle(u)}>×</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
||||||
|
{users.filter(u => u.id !== user.id).map(u => (
|
||||||
|
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
|
||||||
|
<Avatar user={u} size="sm" />
|
||||||
|
<span className="flex-1 text-sm">{u.display_name || u.name}</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-between" style={{ marginTop: 20 }}>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleCreate} disabled={loading}>
|
||||||
|
{loading ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
frontend/src/components/ProfileModal.jsx
Normal file
146
frontend/src/components/ProfileModal.jsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import Avatar from './Avatar.jsx';
|
||||||
|
|
||||||
|
export default function ProfileModal({ onClose }) {
|
||||||
|
const { user, updateUser } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useState(user?.display_name || '');
|
||||||
|
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
|
||||||
|
const [currentPw, setCurrentPw] = useState('');
|
||||||
|
const [newPw, setNewPw] = useState('');
|
||||||
|
const [confirmPw, setConfirmPw] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tab, setTab] = useState('profile'); // 'profile' | 'password'
|
||||||
|
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
||||||
|
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag });
|
||||||
|
updateUser(updated);
|
||||||
|
toast('Profile updated', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const { avatarUrl } = await api.uploadAvatar(file);
|
||||||
|
updateUser({ avatar: avatarUrl });
|
||||||
|
toast('Avatar updated', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
if (newPw !== confirmPw) return toast('Passwords do not match', 'error');
|
||||||
|
if (newPw.length < 8) return toast('Password too short (min 8)', 'error');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.changePassword({ currentPassword: currentPw, newPassword: newPw });
|
||||||
|
toast('Password changed', 'success');
|
||||||
|
setCurrentPw(''); setNewPw(''); setConfirmPw('');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal">
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>My Profile</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex items-center gap-3" style={{ gap: 16, marginBottom: 20 }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Avatar user={user} size="xl" />
|
||||||
|
<label title="Change avatar" style={{
|
||||||
|
position: 'absolute', bottom: 0, right: 0,
|
||||||
|
background: 'var(--primary)', color: 'white', borderRadius: '50%',
|
||||||
|
width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', fontSize: 12
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||||
|
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 16 }}>{user?.display_name || user?.name}</div>
|
||||||
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>{user?.email}</div>
|
||||||
|
<span className={`role-badge role-${user?.role}`}>{user?.role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||||
|
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button>
|
||||||
|
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'profile' && (
|
||||||
|
<div className="flex-col gap-3">
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Display Name</label>
|
||||||
|
<input className="input" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={user?.name} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label>
|
||||||
|
<textarea className="input" value={aboutMe} onChange={e => setAboutMe(e.target.value)} placeholder="Tell your team about yourself..." rows={3} style={{ resize: 'vertical' }} />
|
||||||
|
</div>
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<label className="flex items-center gap-2 text-sm pointer" style={{ color: 'var(--text-secondary)', userSelect: 'none' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideAdminTag}
|
||||||
|
onChange={e => setHideAdminTag(e.target.checked)}
|
||||||
|
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }}
|
||||||
|
/>
|
||||||
|
Hide "Admin" tag next to my name in messages
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
|
||||||
|
{loading ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'password' && (
|
||||||
|
<div className="flex-col gap-3">
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label>
|
||||||
|
<input className="input" type="password" value={currentPw} onChange={e => setCurrentPw(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label>
|
||||||
|
<input className="input" type="password" value={newPw} onChange={e => setNewPw(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Confirm New Password</label>
|
||||||
|
<input className="input" type="password" value={confirmPw} onChange={e => setConfirmPw(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={handleChangePassword} disabled={loading || !currentPw || !newPw}>
|
||||||
|
{loading ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
frontend/src/components/SettingsModal.jsx
Normal file
243
frontend/src/components/SettingsModal.jsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
|
||||||
|
function IconUploadRow({ label, settingKey, currentUrl, onUploaded, defaultSvg }) {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const handleUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 1024 * 1024) return toast(`${label} icon must be less than 1MB`, 'error');
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (settingKey === 'icon_newchat') result = await api.uploadIconNewChat(file);
|
||||||
|
else result = await api.uploadIconGroupInfo(file);
|
||||||
|
onUploaded(settingKey, result.iconUrl);
|
||||||
|
toast(`${label} icon updated`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 48, height: 48, borderRadius: 10, background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||||
|
}}>
|
||||||
|
{currentUrl ? (
|
||||||
|
<img src={currentUrl} alt={label} style={{ width: 32, height: 32, objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<span style={{ opacity: 0.35 }}>{defaultSvg}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{label}</div>
|
||||||
|
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||||
|
Upload PNG
|
||||||
|
<input type="file" accept="image/png,image/svg+xml,image/*" style={{ display: 'none' }} onChange={handleUpload} />
|
||||||
|
</label>
|
||||||
|
{currentUrl && (
|
||||||
|
<span style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-tertiary)' }}>Custom icon active</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsModal({ onClose }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [settings, setSettings] = useState({});
|
||||||
|
const [appName, setAppName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSettings().then(({ settings }) => {
|
||||||
|
setSettings(settings);
|
||||||
|
setAppName(settings.app_name || 'TeamChat');
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const notifySidebarRefresh = () => window.dispatchEvent(new Event('teamchat:settings-changed'));
|
||||||
|
|
||||||
|
const handleSaveName = async () => {
|
||||||
|
if (!appName.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.updateAppName(appName.trim());
|
||||||
|
setSettings(prev => ({ ...prev, app_name: appName.trim() }));
|
||||||
|
toast('App name updated', 'success');
|
||||||
|
notifySidebarRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 1024 * 1024) return toast('Logo must be less than 1MB', 'error');
|
||||||
|
try {
|
||||||
|
const { logoUrl } = await api.uploadLogo(file);
|
||||||
|
setSettings(prev => ({ ...prev, logo_url: logoUrl }));
|
||||||
|
toast('Logo updated', 'success');
|
||||||
|
notifySidebarRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIconUploaded = (key, url) => {
|
||||||
|
setSettings(prev => ({ ...prev, [key]: url }));
|
||||||
|
notifySidebarRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
await api.resetSettings();
|
||||||
|
const { settings: fresh } = await api.getSettings();
|
||||||
|
setSettings(fresh);
|
||||||
|
setAppName(fresh.app_name || 'TeamChat');
|
||||||
|
toast('Settings reset to defaults', 'success');
|
||||||
|
notifySidebarRefresh();
|
||||||
|
setShowResetConfirm(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const newChatSvg = (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const groupInfoSvg = (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" style={{ maxWidth: 460 }}>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>App Settings</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App Logo */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div className="settings-section-label">App Logo</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 72, height: 72, borderRadius: 16, background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||||
|
}}>
|
||||||
|
{settings.logo_url ? (
|
||||||
|
<img src={settings.logo_url} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" style={{ width: 48, height: 48 }}>
|
||||||
|
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||||
|
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||||
|
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||||
|
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||||
|
Upload Logo
|
||||||
|
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>Square format, max 1MB. Used in sidebar, login page and browser tab.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App Name */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div className="settings-section-label">App Name</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input className="input flex-1" value={appName} onChange={e => setAppName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>
|
||||||
|
{loading ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Icons */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div className="settings-section-label">Interface Icons</div>
|
||||||
|
<IconUploadRow
|
||||||
|
label="New Chat Button"
|
||||||
|
settingKey="icon_newchat"
|
||||||
|
currentUrl={settings.icon_newchat}
|
||||||
|
onUploaded={handleIconUploaded}
|
||||||
|
defaultSvg={newChatSvg}
|
||||||
|
/>
|
||||||
|
<IconUploadRow
|
||||||
|
label="Group Info Button"
|
||||||
|
settingKey="icon_groupinfo"
|
||||||
|
currentUrl={settings.icon_groupinfo}
|
||||||
|
onUploaded={handleIconUploaded}
|
||||||
|
defaultSvg={groupInfoSvg}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset + Version */}
|
||||||
|
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
|
||||||
|
<div className="settings-section-label">Reset</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
{!showResetConfirm ? (
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>
|
||||||
|
Reset All to Defaults
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
background: '#fce8e6', border: '1px solid #f5c6c2',
|
||||||
|
borderRadius: 'var(--radius)', padding: '12px 14px'
|
||||||
|
}}>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
|
||||||
|
This will reset the app name, logo, and all custom icons to their install defaults. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
|
||||||
|
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{settings.app_version && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||||||
|
v{settings.app_version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>{/* end flex row */}
|
||||||
|
</div>{/* end Reset section */}
|
||||||
|
|
||||||
|
{settings.pw_reset_active === 'true' && (
|
||||||
|
<div className="warning-banner">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span><strong>PW_RESET is active.</strong> The default admin password is being reset on every restart. Set PW_RESET=false in your environment variables to stop this.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
frontend/src/components/Sidebar.css
Normal file
201
frontend/src/components/Sidebar.css
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
.sidebar {
|
||||||
|
width: 320px;
|
||||||
|
min-width: 320px;
|
||||||
|
height: 100vh;
|
||||||
|
background: white;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sidebar { width: 100vw; min-width: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search {
|
||||||
|
padding: 12px 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px 8px 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--background);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
.search-input:focus { outline: none; }
|
||||||
|
.search-input::placeholder { color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
.groups-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-section { margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
padding: 8px 16px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.group-item:hover { background: var(--background); }
|
||||||
|
.group-item.active { background: var(--primary-light); }
|
||||||
|
.group-item.active .group-name { color: var(--primary); font-weight: 600; }
|
||||||
|
|
||||||
|
.group-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-last-msg {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.sidebar-footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-footer-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background var(--transition);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.user-footer-btn:hover { background: var(--background); }
|
||||||
|
|
||||||
|
.footer-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 68px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px;
|
||||||
|
z-index: 100;
|
||||||
|
animation: slideUp 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.footer-menu-item:hover { background: var(--background); }
|
||||||
|
.footer-menu-item.danger { color: var(--error); }
|
||||||
|
.footer-menu-item.danger:hover { background: #fce8e6; }
|
||||||
|
|
||||||
|
/* App logo in sidebar header */
|
||||||
|
.sidebar-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-default {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-default svg {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
222
frontend/src/components/Sidebar.jsx
Normal file
222
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import Avatar from './Avatar.jsx';
|
||||||
|
import './Sidebar.css';
|
||||||
|
|
||||||
|
function useAppSettings() {
|
||||||
|
const [settings, setSettings] = useState({ app_name: 'TeamChat', logo_url: '' });
|
||||||
|
|
||||||
|
const fetchSettings = () => {
|
||||||
|
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
// Re-fetch when settings are saved from the SettingsModal
|
||||||
|
window.addEventListener('teamchat:settings-changed', fetchSettings);
|
||||||
|
return () => window.removeEventListener('teamchat:settings-changed', fetchSettings);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update page title and favicon whenever settings change
|
||||||
|
useEffect(() => {
|
||||||
|
const name = settings.app_name || 'TeamChat';
|
||||||
|
|
||||||
|
// Update <title>
|
||||||
|
document.title = name;
|
||||||
|
|
||||||
|
// Update favicon
|
||||||
|
const logoUrl = settings.logo_url;
|
||||||
|
const faviconUrl = logoUrl || '/logo.svg';
|
||||||
|
let link = document.querySelector("link[rel~='icon']");
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.href = faviconUrl;
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Set(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { connected } = useSocket();
|
||||||
|
const toast = useToast();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const settings = useAppSettings();
|
||||||
|
|
||||||
|
const appName = settings.app_name || 'TeamChat';
|
||||||
|
const logoUrl = settings.logo_url;
|
||||||
|
|
||||||
|
const allGroups = [
|
||||||
|
...(groups.publicGroups || []),
|
||||||
|
...(groups.privateGroups || [])
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = search
|
||||||
|
? allGroups.filter(g => g.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: allGroups;
|
||||||
|
|
||||||
|
const publicFiltered = filtered.filter(g => g.type === 'public');
|
||||||
|
const privateFiltered = filtered.filter(g => g.type === 'private');
|
||||||
|
|
||||||
|
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupItem = ({ group }) => {
|
||||||
|
const notifs = getNotifCount(group.id);
|
||||||
|
const hasUnread = unreadGroups.has(group.id);
|
||||||
|
const isActive = group.id === activeGroupId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`} onClick={() => onSelectGroup(group.id)}>
|
||||||
|
<div className="group-icon" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}>
|
||||||
|
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="group-info flex-1 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>{group.name}</span>
|
||||||
|
{group.last_message_at && (
|
||||||
|
<span className="group-time">{formatTime(group.last_message_at)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="group-last-msg truncate">
|
||||||
|
{group.last_message || (group.is_readonly ? '📢 Read-only' : 'No messages yet')}
|
||||||
|
</span>
|
||||||
|
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
|
||||||
|
{hasUnread && notifs === 0 && <span className="unread-dot shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar">
|
||||||
|
{/* Header with live app name and logo */}
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<div className="flex items-center gap-2 flex-1" style={{ minWidth: 0 }}>
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt={appName} className="sidebar-logo" />
|
||||||
|
) : (
|
||||||
|
<div className="sidebar-logo-default">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||||
|
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||||
|
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||||
|
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="sidebar-title truncate">{appName}</h2>
|
||||||
|
{!connected && <span className="offline-dot" title="Offline" />}
|
||||||
|
</div>
|
||||||
|
<button className="btn-icon" onClick={onNewChat} title="New Chat">
|
||||||
|
{settings.icon_newchat ? (
|
||||||
|
<img src={settings.icon_newchat} alt="New Chat" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="sidebar-search">
|
||||||
|
<div className="search-wrap">
|
||||||
|
<svg className="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input className="search-input" placeholder="Search chats..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Groups list */}
|
||||||
|
<div className="groups-list">
|
||||||
|
{publicFiltered.length > 0 && (
|
||||||
|
<div className="group-section">
|
||||||
|
<div className="section-label">CHANNELS</div>
|
||||||
|
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{privateFiltered.length > 0 && (
|
||||||
|
<div className="group-section">
|
||||||
|
<div className="section-label">DIRECT MESSAGES</div>
|
||||||
|
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
|
||||||
|
No chats found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User footer */}
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<button className="user-footer-btn" onClick={() => setShowMenu(!showMenu)}>
|
||||||
|
<Avatar user={user} size="sm" />
|
||||||
|
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
|
||||||
|
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
|
||||||
|
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showMenu && (
|
||||||
|
<div className="footer-menu" onClick={() => setShowMenu(false)}>
|
||||||
|
<button className="footer-menu-item" onClick={onProfile}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<>
|
||||||
|
<button className="footer-menu-item" onClick={onUsers}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
User Manager
|
||||||
|
</button>
|
||||||
|
<button className="footer-menu-item" onClick={onOpenSettings}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||||
|
<button className="footer-menu-item danger" onClick={handleLogout}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
if (diff < 86400000 && date.getDate() === now.getDate()) {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
if (diff < 604800000) {
|
||||||
|
return date.toLocaleDateString([], { weekday: 'short' });
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
199
frontend/src/components/SupportModal.jsx
Normal file
199
frontend/src/components/SupportModal.jsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
|
||||||
|
function generateCaptcha() {
|
||||||
|
const a = Math.floor(Math.random() * 9) + 1;
|
||||||
|
const b = Math.floor(Math.random() * 9) + 1;
|
||||||
|
const ops = [
|
||||||
|
{ label: `${a} + ${b}`, answer: a + b },
|
||||||
|
{ label: `${a + b} - ${b}`, answer: a },
|
||||||
|
{ label: `${a} × ${b}`, answer: a * b },
|
||||||
|
];
|
||||||
|
return ops[Math.floor(Math.random() * ops.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupportModal({ onClose }) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||||
|
const [captcha, setCaptcha] = useState(generateCaptcha);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const refreshCaptcha = () => {
|
||||||
|
setCaptcha(generateCaptcha());
|
||||||
|
setCaptchaAnswer('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!name.trim() || !email.trim() || !message.trim()) {
|
||||||
|
return setError('Please fill in all fields.');
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return setError('Please enter a valid email address.');
|
||||||
|
}
|
||||||
|
if (parseInt(captchaAnswer, 10) !== captcha.answer) {
|
||||||
|
setError('Incorrect answer — please try again.');
|
||||||
|
refreshCaptcha();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.submitSupport({ name, email, message });
|
||||||
|
setSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Failed to send. Please try again.');
|
||||||
|
refreshCaptcha();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" style={{ maxWidth: 440 }}>
|
||||||
|
{sent ? (
|
||||||
|
/* Success state */
|
||||||
|
<div style={{ textAlign: 'center', padding: '8px 0 16px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 56, height: 56, borderRadius: '50%',
|
||||||
|
background: '#e6f4ea', display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', margin: '0 auto 16px'
|
||||||
|
}}>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34a853" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>Message Sent</h3>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 24, lineHeight: 1.5 }}>
|
||||||
|
Your message has been received. An administrator will follow up with you shortly.
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-primary" onClick={onClose} style={{ minWidth: 120 }}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Form state */
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 6 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>Contact Support</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 20 }}>
|
||||||
|
Fill out the form below and an administrator will get back to you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Your Name</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Jane Smith"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Your Email</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
placeholder="Describe your issue or question..."
|
||||||
|
value={message}
|
||||||
|
onChange={e => setMessage(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
maxLength={2000}
|
||||||
|
style={{ resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)', alignSelf: 'flex-end' }}>
|
||||||
|
{message.length}/2000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Math captcha */}
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Security Check
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2" style={{ gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--background)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)', padding: '9px 16px',
|
||||||
|
fontSize: 15, fontWeight: 700, letterSpacing: 2,
|
||||||
|
color: 'var(--text-primary)', fontFamily: 'monospace',
|
||||||
|
flexShrink: 0, userSelect: 'none'
|
||||||
|
}}>
|
||||||
|
{captcha.label} = ?
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
placeholder="Answer"
|
||||||
|
value={captchaAnswer}
|
||||||
|
onChange={e => setCaptchaAnswer(e.target.value)}
|
||||||
|
style={{ width: 90 }}
|
||||||
|
min={0}
|
||||||
|
max={999}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={refreshCaptcha}
|
||||||
|
title="New question"
|
||||||
|
style={{ color: 'var(--text-secondary)', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background: '#fce8e6', border: '1px solid #f5c6c2',
|
||||||
|
borderRadius: 'var(--radius)', padding: '10px 14px',
|
||||||
|
fontSize: 13, color: 'var(--error)'
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={loading} style={{ marginTop: 4 }}>
|
||||||
|
{loading
|
||||||
|
? <><span className="spinner" style={{ width: 16, height: 16 }} /> Sending...</>
|
||||||
|
: 'Send Message'
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
frontend/src/components/UserManagerModal.jsx
Normal file
288
frontend/src/components/UserManagerModal.jsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import Avatar from './Avatar.jsx';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
|
||||||
|
export default function UserManagerModal({ onClose }) {
|
||||||
|
const { user: me } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [tab, setTab] = useState('users'); // 'users' | 'create' | 'bulk'
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' });
|
||||||
|
const [bulkPreview, setBulkPreview] = useState([]);
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
|
const [resetingId, setResetingId] = useState(null);
|
||||||
|
const [resetPw, setResetPw] = useState('');
|
||||||
|
const fileRef = useRef(null);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const filtered = users.filter(u =>
|
||||||
|
!search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!form.name || !form.email || !form.password) return toast('All fields required', 'error');
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await api.createUser(form);
|
||||||
|
toast('User created', 'success');
|
||||||
|
setForm({ name: '', email: '', password: '', role: 'member' });
|
||||||
|
setTab('users');
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCSV = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
Papa.parse(file, {
|
||||||
|
header: true,
|
||||||
|
complete: ({ data }) => {
|
||||||
|
const rows = data.filter(r => r.email).map(r => ({
|
||||||
|
name: r.name || r.Name || '',
|
||||||
|
email: r.email || r.Email || '',
|
||||||
|
password: r.password || r.Password || 'TempPass@123',
|
||||||
|
role: (r.role || r.Role || 'member').toLowerCase(),
|
||||||
|
}));
|
||||||
|
setBulkPreview(rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkImport = async () => {
|
||||||
|
if (!bulkPreview.length) return;
|
||||||
|
setBulkLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await api.bulkUsers(bulkPreview);
|
||||||
|
toast(`Created: ${results.created.length}, Errors: ${results.errors.length}`, results.errors.length ? 'default' : 'success');
|
||||||
|
setBulkPreview([]);
|
||||||
|
setTab('users');
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setBulkLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRole = async (u, role) => {
|
||||||
|
try {
|
||||||
|
await api.updateRole(u.id, role);
|
||||||
|
toast('Role updated', 'success');
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPw = async (uid) => {
|
||||||
|
if (!resetPw || resetPw.length < 6) return toast('Enter a password (min 6 chars)', 'error');
|
||||||
|
try {
|
||||||
|
await api.resetPassword(uid, resetPw);
|
||||||
|
toast('Password reset — user must change on next login', 'success');
|
||||||
|
setResetingId(null);
|
||||||
|
setResetPw('');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuspend = async (u) => {
|
||||||
|
if (!confirm(`Suspend ${u.name}?`)) return;
|
||||||
|
try { await api.suspendUser(u.id); toast('User suspended', 'success'); load(); }
|
||||||
|
catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = async (u) => {
|
||||||
|
try { await api.activateUser(u.id); toast('User activated', 'success'); load(); }
|
||||||
|
catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (u) => {
|
||||||
|
if (!confirm(`Delete ${u.name}? Their messages will remain but they cannot log in.`)) return;
|
||||||
|
try { await api.deleteUser(u.id); toast('User deleted', 'success'); load(); }
|
||||||
|
catch (e) { toast(e.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" style={{ maxWidth: 700 }}>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
|
||||||
|
<button className="btn-icon" onClick={onClose}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||||
|
<button className={`btn btn-sm ${tab === 'users' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('users')}>
|
||||||
|
All Users ({users.length})
|
||||||
|
</button>
|
||||||
|
<button className={`btn btn-sm ${tab === 'create' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('create')}>
|
||||||
|
+ Create User
|
||||||
|
</button>
|
||||||
|
<button className={`btn btn-sm ${tab === 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('bulk')}>
|
||||||
|
Bulk Import CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users list */}
|
||||||
|
{tab === 'users' && (
|
||||||
|
<>
|
||||||
|
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
|
||||||
|
) : (
|
||||||
|
<div style={{ maxHeight: 440, overflowY: 'auto' }}>
|
||||||
|
{filtered.map(u => (
|
||||||
|
<div key={u.id} style={{ borderBottom: '1px solid var(--border)', padding: '12px 0' }}>
|
||||||
|
<div className="flex items-center gap-2" style={{ gap: 12 }}>
|
||||||
|
<Avatar user={u} size="sm" />
|
||||||
|
<div className="flex-col flex-1 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2" style={{ gap: 8 }}>
|
||||||
|
<span className="font-medium text-sm">{u.display_name || u.name}</span>
|
||||||
|
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
||||||
|
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
|
||||||
|
{u.is_default_admin ? <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Default Admin</span> : null}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{u.email}</span>
|
||||||
|
{u.must_change_password ? <span className="text-xs" style={{ color: 'var(--warning)' }}>⚠ Must change password</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{!u.is_default_admin && (
|
||||||
|
<div className="flex gap-1" style={{ gap: 4 }}>
|
||||||
|
{resetingId === u.id ? (
|
||||||
|
<div className="flex gap-1" style={{ gap: 4 }}>
|
||||||
|
<input className="input" style={{ width: 130, fontSize: 12, padding: '4px 8px' }} type="password" placeholder="New password" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => handleResetPw(u.id)}>Set</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => { setResetingId(null); setResetPw(''); }}>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setResetingId(u.id)} title="Reset password">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
Reset PW
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={u.role}
|
||||||
|
onChange={e => handleRole(u, e.target.value)}
|
||||||
|
className="input"
|
||||||
|
style={{ width: 90, padding: '4px 6px', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<option value="member">Member</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
{u.status === 'active' ? (
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => handleSuspend(u)}>Suspend</button>
|
||||||
|
) : u.status === 'suspended' ? (
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => handleActivate(u)} style={{ color: 'var(--success)' }}>Activate</button>
|
||||||
|
) : null}
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(u)}>Delete</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create user */}
|
||||||
|
{tab === 'create' && (
|
||||||
|
<div className="flex-col gap-3">
|
||||||
|
<div className="flex gap-3" style={{ gap: 12 }}>
|
||||||
|
<div className="flex-col gap-1 flex-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Full Name</label>
|
||||||
|
<input className="input" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-col gap-1 flex-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Email</label>
|
||||||
|
<input className="input" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3" style={{ gap: 12 }}>
|
||||||
|
<div className="flex-col gap-1 flex-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password</label>
|
||||||
|
<input className="input" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Role</label>
|
||||||
|
<select className="input" value={form.role} onChange={e => setForm(p => ({ ...p, role: e.target.value }))}>
|
||||||
|
<option value="member">Member</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>User will be required to change their password on first login.</p>
|
||||||
|
<button className="btn btn-primary" onClick={handleCreate} disabled={creating}>{creating ? 'Creating...' : 'Create User'}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk import */}
|
||||||
|
{tab === 'bulk' && (
|
||||||
|
<div className="flex-col gap-4">
|
||||||
|
<div className="card" style={{ background: 'var(--background)', border: '1px dashed var(--border)' }}>
|
||||||
|
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>CSV Format</p>
|
||||||
|
<code style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'block', background: 'white', padding: 8, borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||||
|
name,email,password,role{'\n'}
|
||||||
|
John Doe,john@example.com,TempPass123,member
|
||||||
|
</code>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||||
|
role can be "member" or "admin". Password defaults to TempPass@123 if omitted. All users must change password on first login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="btn btn-secondary pointer" style={{ alignSelf: 'flex-start' }}>
|
||||||
|
Select CSV File
|
||||||
|
<input ref={fileRef} type="file" accept=".csv" style={{ display: 'none' }} onChange={handleCSV} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{bulkPreview.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>Preview ({bulkPreview.length} users)</p>
|
||||||
|
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', maxHeight: 200, overflowY: 'auto' }}>
|
||||||
|
{bulkPreview.slice(0, 10).map((u, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2" style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 13, gap: 12 }}>
|
||||||
|
<span className="flex-1">{u.name}</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{u.email}</span>
|
||||||
|
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{bulkPreview.length > 10 && (
|
||||||
|
<div style={{ padding: '8px 12px', color: 'var(--text-tertiary)', fontSize: 13 }}>
|
||||||
|
...and {bulkPreview.length - 10} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={handleBulkImport} disabled={bulkLoading}>
|
||||||
|
{bulkLoading ? 'Importing...' : `Import ${bulkPreview.length} Users`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
frontend/src/components/UserProfilePopup.jsx
Normal file
81
frontend/src/components/UserProfilePopup.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import Avatar from './Avatar.jsx';
|
||||||
|
|
||||||
|
export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
||||||
|
const popupRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
if (popupRef.current && !popupRef.current.contains(e.target) &&
|
||||||
|
anchorEl && !anchorEl.contains(e.target)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [anchorEl, onClose]);
|
||||||
|
|
||||||
|
// Position near the anchor element
|
||||||
|
useEffect(() => {
|
||||||
|
if (!popupRef.current || !anchorEl) return;
|
||||||
|
const anchor = anchorEl.getBoundingClientRect();
|
||||||
|
const popup = popupRef.current;
|
||||||
|
const viewportH = window.innerHeight;
|
||||||
|
const viewportW = window.innerWidth;
|
||||||
|
|
||||||
|
// Default: below and to the right of avatar
|
||||||
|
let top = anchor.bottom + 8;
|
||||||
|
let left = anchor.left;
|
||||||
|
|
||||||
|
// Flip up if not enough space below
|
||||||
|
if (top + 220 > viewportH) top = anchor.top - 228;
|
||||||
|
// Clamp right edge
|
||||||
|
if (left + 220 > viewportW) left = viewportW - 228;
|
||||||
|
|
||||||
|
popup.style.top = `${top}px`;
|
||||||
|
popup.style.left = `${left}px`;
|
||||||
|
}, [anchorEl]);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={popupRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||||
|
width: 220,
|
||||||
|
padding: '20px 16px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar user={user} size="xl" />
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)', marginBottom: 2 }}>
|
||||||
|
{user.display_name || user.name}
|
||||||
|
</div>
|
||||||
|
{user.role === 'admin' && !user.hide_admin_tag && (
|
||||||
|
<span className="role-badge role-admin" style={{ fontSize: 11 }}>Admin</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{user.about_me && (
|
||||||
|
<p style={{
|
||||||
|
fontSize: 13, color: 'var(--text-secondary)',
|
||||||
|
textAlign: 'center', lineHeight: 1.5,
|
||||||
|
marginTop: 4, wordBreak: 'break-word',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
paddingTop: 10, width: '100%'
|
||||||
|
}}>
|
||||||
|
{user.about_me}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/contexts/AuthContext.jsx
Normal file
58
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [mustChangePassword, setMustChangePassword] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||||
|
if (token) {
|
||||||
|
api.me()
|
||||||
|
.then(({ user }) => {
|
||||||
|
setUser(user);
|
||||||
|
setMustChangePassword(!!user.must_change_password);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('tc_token');
|
||||||
|
sessionStorage.removeItem('tc_token');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email, password, rememberMe) => {
|
||||||
|
const data = await api.login({ email, password, rememberMe });
|
||||||
|
if (rememberMe) {
|
||||||
|
localStorage.setItem('tc_token', data.token);
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem('tc_token', data.token);
|
||||||
|
}
|
||||||
|
setUser(data.user);
|
||||||
|
setMustChangePassword(!!data.mustChangePassword);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try { await api.logout(); } catch {}
|
||||||
|
localStorage.removeItem('tc_token');
|
||||||
|
sessionStorage.removeItem('tc_token');
|
||||||
|
setUser(null);
|
||||||
|
setMustChangePassword(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, mustChangePassword, setMustChangePassword, login, logout, updateUser }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
46
frontend/src/contexts/SocketContext.jsx
Normal file
46
frontend/src/contexts/SocketContext.jsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import { useAuth } from './AuthContext.jsx';
|
||||||
|
|
||||||
|
const SocketContext = createContext(null);
|
||||||
|
|
||||||
|
export function SocketProvider({ children }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const socketRef = useRef(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [onlineUsers, setOnlineUsers] = useState(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||||
|
const socket = io('/', { auth: { token }, transports: ['websocket'] });
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setConnected(true);
|
||||||
|
socket.emit('users:online');
|
||||||
|
});
|
||||||
|
socket.on('disconnect', () => setConnected(false));
|
||||||
|
socket.on('users:online', ({ userIds }) => setOnlineUsers(new Set(userIds)));
|
||||||
|
socket.on('user:online', ({ userId }) => setOnlineUsers(prev => new Set([...prev, userId])));
|
||||||
|
socket.on('user:offline', ({ userId }) => setOnlineUsers(prev => { const s = new Set(prev); s.delete(userId); return s; }));
|
||||||
|
|
||||||
|
return () => { socket.disconnect(); socketRef.current = null; };
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider value={{ socket: socketRef.current, connected, onlineUsers }}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSocket = () => useContext(SocketContext);
|
||||||
28
frontend/src/contexts/ToastContext.jsx
Normal file
28
frontend/src/contexts/ToastContext.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
|
||||||
|
|
||||||
|
const ToastContext = createContext(null);
|
||||||
|
|
||||||
|
let toastIdCounter = 0;
|
||||||
|
|
||||||
|
export function ToastProvider({ children }) {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
const toast = useCallback((msg, type = 'default', duration = 3000) => {
|
||||||
|
const id = ++toastIdCounter;
|
||||||
|
setToasts(prev => [...prev, { id, msg, type }]);
|
||||||
|
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), duration);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={toast}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div key={t.id} className={`toast ${t.type}`}>{t.msg}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToast = () => useContext(ToastContext);
|
||||||
199
frontend/src/index.css
Normal file
199
frontend/src/index.css
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600;700&family=Roboto:wght@300;400;500&display=swap');
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #1a73e8;
|
||||||
|
--primary-dark: #1557b0;
|
||||||
|
--primary-light: #e8f0fe;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-variant: #f8f9fa;
|
||||||
|
--background: #f1f3f4;
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--text-primary: #202124;
|
||||||
|
--text-secondary: #5f6368;
|
||||||
|
--text-tertiary: #9aa0a6;
|
||||||
|
--error: #d93025;
|
||||||
|
--success: #188038;
|
||||||
|
--warning: #e37400;
|
||||||
|
--bubble-out: #1a73e8;
|
||||||
|
--bubble-in: #f1f3f4;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 24px;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.12);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
--shadow-lg: 0 4px 20px rgba(0,0,0,0.18);
|
||||||
|
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--font: 'Google Sans', 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root { height: 100%; font-family: var(--font); color: var(--text-primary); background: var(--background); }
|
||||||
|
|
||||||
|
button { font-family: var(--font); cursor: pointer; border: none; background: none; }
|
||||||
|
input, textarea { font-family: var(--font); }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
/* Scrollbars */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Focus */
|
||||||
|
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Utils */
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-col { display: flex; flex-direction: column; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.gap-1 { gap: 4px; }
|
||||||
|
.gap-2 { gap: 8px; }
|
||||||
|
.gap-3 { gap: 12px; }
|
||||||
|
.gap-4 { gap: 16px; }
|
||||||
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.h-full { height: 100%; }
|
||||||
|
.relative { position: relative; }
|
||||||
|
.absolute { position: absolute; }
|
||||||
|
.overflow-hidden { overflow: hidden; }
|
||||||
|
.overflow-y-auto { overflow-y: auto; }
|
||||||
|
.flex-1 { flex: 1; min-width: 0; }
|
||||||
|
.shrink-0 { flex-shrink: 0; }
|
||||||
|
.text-sm { font-size: 13px; }
|
||||||
|
.text-xs { font-size: 11px; }
|
||||||
|
.font-medium { font-weight: 500; }
|
||||||
|
.font-semibold { font-weight: 600; }
|
||||||
|
.opacity-60 { opacity: 0.6; }
|
||||||
|
.pointer { cursor: pointer; }
|
||||||
|
.rounded { border-radius: var(--radius); }
|
||||||
|
.rounded-full { border-radius: 9999px; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 20px; border-radius: 20px; font-size: 14px; font-weight: 500;
|
||||||
|
transition: var(--transition); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--primary); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--primary-dark); box-shadow: var(--shadow-sm); }
|
||||||
|
.btn-secondary { background: transparent; color: var(--primary); border: 1px solid var(--border); }
|
||||||
|
.btn-secondary:hover { background: var(--primary-light); }
|
||||||
|
.btn-ghost { background: transparent; color: var(--text-secondary); }
|
||||||
|
.btn-ghost:hover { background: var(--background); color: var(--text-primary); }
|
||||||
|
.btn-danger { background: var(--error); color: white; }
|
||||||
|
.btn-danger:hover { opacity: 0.9; }
|
||||||
|
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||||
|
.btn-icon { padding: 8px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.btn-icon:hover { background: var(--background); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
.input {
|
||||||
|
width: 100%; padding: 10px 14px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); font-size: 14px; background: white;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(26,115,232,0.15); }
|
||||||
|
.input::placeholder { color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card {
|
||||||
|
background: white; border-radius: var(--radius-lg); padding: 20px;
|
||||||
|
box-shadow: var(--shadow-sm); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal overlay */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000; padding: 16px;
|
||||||
|
animation: fadeIn 150ms ease;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: white; border-radius: var(--radius-xl); padding: 24px;
|
||||||
|
width: 100%; max-width: 480px; box-shadow: var(--shadow-lg);
|
||||||
|
animation: slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
max-height: 90vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; }
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.avatar {
|
||||||
|
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 600; font-size: 14px; flex-shrink: 0; overflow: hidden;
|
||||||
|
background: var(--primary); color: white; user-select: none;
|
||||||
|
}
|
||||||
|
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.avatar-sm { width: 32px; height: 32px; font-size: 12px; }
|
||||||
|
.avatar-md { width: 40px; height: 40px; font-size: 15px; }
|
||||||
|
.avatar-lg { width: 48px; height: 48px; font-size: 18px; }
|
||||||
|
.avatar-xl { width: 72px; height: 72px; font-size: 24px; }
|
||||||
|
|
||||||
|
/* Badge */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px;
|
||||||
|
font-size: 11px; font-weight: 600; background: var(--primary); color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@keyframes slideIn { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.spinner {
|
||||||
|
width: 24px; height: 24px; border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--primary); border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.toast {
|
||||||
|
padding: 12px 20px; border-radius: var(--radius); background: #323232; color: white;
|
||||||
|
font-size: 14px; box-shadow: var(--shadow-md); animation: slideIn 200ms ease;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.toast.error { background: var(--error); }
|
||||||
|
.toast.success { background: var(--success); }
|
||||||
|
|
||||||
|
/* Chip */
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 4px 10px; border-radius: 16px; font-size: 12px; font-weight: 500;
|
||||||
|
background: var(--primary-light); color: var(--primary);
|
||||||
|
}
|
||||||
|
.chip-remove { cursor: pointer; opacity: 0.7; font-size: 14px; }
|
||||||
|
.chip-remove:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
.divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
|
||||||
|
|
||||||
|
/* Warning banner */
|
||||||
|
.warning-banner {
|
||||||
|
background: #fff3e0; border: 1px solid #ff9800; border-radius: var(--radius);
|
||||||
|
padding: 12px 16px; font-size: 13px; color: #e65100; display: flex; gap: 8px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Role badge */
|
||||||
|
.role-badge {
|
||||||
|
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.role-admin { background: #fce8e6; color: #c5221f; }
|
||||||
|
.role-member { background: var(--primary-light); color: var(--primary); }
|
||||||
|
.status-suspended { background: #fff3e0; color: #e65100; }
|
||||||
|
|
||||||
|
.settings-section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
25
frontend/src/main.jsx
Normal file
25
frontend/src/main.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
// Register service worker
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(reg => console.log('[SW] Registered, scope:', reg.scope))
|
||||||
|
.catch(err => console.error('[SW] Registration failed:', err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear badge count when user focuses the app
|
||||||
|
window.addEventListener('focus', () => {
|
||||||
|
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
||||||
|
navigator.serviceWorker?.controller?.postMessage({ type: 'CLEAR_BADGE' });
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
60
frontend/src/pages/ChangePassword.jsx
Normal file
60
frontend/src/pages/ChangePassword.jsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
|
||||||
|
export default function ChangePassword() {
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { setMustChangePassword } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (next !== confirm) return toast('Passwords do not match', 'error');
|
||||||
|
if (next.length < 8) return toast('Password must be at least 8 characters', 'error');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.changePassword({ currentPassword: current, newPassword: next });
|
||||||
|
setMustChangePassword(false);
|
||||||
|
toast('Password changed!', 'success');
|
||||||
|
nav('/');
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--background)', padding: 20 }}>
|
||||||
|
<div className="card" style={{ width: '100%', maxWidth: 420 }}>
|
||||||
|
<h2 style={{ marginBottom: 8, fontSize: 22, fontWeight: 700 }}>Change Password</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: 24, fontSize: 14 }}>
|
||||||
|
You must set a new password before continuing.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label>
|
||||||
|
<input className="input" type="password" value={current} onChange={e => setCurrent(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label>
|
||||||
|
<input className="input" type="password" value={next} onChange={e => setNext(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="flex-col gap-1">
|
||||||
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Confirm New Password</label>
|
||||||
|
<input className="input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Saving...' : 'Set New Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/src/pages/Chat.css
Normal file
12
frontend/src/pages/Chat.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.chat-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.chat-layout {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
frontend/src/pages/Chat.jsx
Normal file
171
frontend/src/pages/Chat.jsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import Sidebar from '../components/Sidebar.jsx';
|
||||||
|
import ChatWindow from '../components/ChatWindow.jsx';
|
||||||
|
import ProfileModal from '../components/ProfileModal.jsx';
|
||||||
|
import UserManagerModal from '../components/UserManagerModal.jsx';
|
||||||
|
import SettingsModal from '../components/SettingsModal.jsx';
|
||||||
|
import NewChatModal from '../components/NewChatModal.jsx';
|
||||||
|
import './Chat.css';
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String) {
|
||||||
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const outputArray = new Uint8Array(rawData.length);
|
||||||
|
for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Chat() {
|
||||||
|
const { socket } = useSocket();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
|
||||||
|
const [activeGroupId, setActiveGroupId] = useState(null);
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
const [unreadGroups, setUnreadGroups] = useState(new Set());
|
||||||
|
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat'
|
||||||
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
|
const [showSidebar, setShowSidebar] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = () => {
|
||||||
|
const mobile = window.innerWidth < 768;
|
||||||
|
setIsMobile(mobile);
|
||||||
|
if (!mobile) setShowSidebar(true);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handle);
|
||||||
|
return () => window.removeEventListener('resize', handle);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadGroups = useCallback(() => {
|
||||||
|
api.getGroups().then(setGroups).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadGroups(); }, [loadGroups]);
|
||||||
|
|
||||||
|
// Register push subscription
|
||||||
|
useEffect(() => {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
|
||||||
|
const existing = await reg.pushManager.getSubscription();
|
||||||
|
if (existing) {
|
||||||
|
// Re-register to keep subscription fresh
|
||||||
|
await fetch('/api/push/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
|
||||||
|
body: JSON.stringify(existing.toJSON())
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') return;
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||||||
|
});
|
||||||
|
await fetch('/api/push/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token')}` },
|
||||||
|
body: JSON.stringify(sub.toJSON())
|
||||||
|
});
|
||||||
|
console.log('[Push] Subscribed');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Push] Subscription failed:', e.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Socket message events to update group previews
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const handleNewMsg = (msg) => {
|
||||||
|
setGroups(prev => {
|
||||||
|
const updateGroup = (g) => g.id === msg.group_id
|
||||||
|
? { ...g, last_message: msg.content || (msg.image_url ? '📷 Image' : ''), last_message_at: msg.created_at }
|
||||||
|
: g;
|
||||||
|
return {
|
||||||
|
publicGroups: prev.publicGroups.map(updateGroup),
|
||||||
|
privateGroups: prev.privateGroups.map(updateGroup),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotification = (notif) => {
|
||||||
|
if (notif.type === 'private_message') {
|
||||||
|
// Show unread dot on private group in sidebar (if not currently viewing it)
|
||||||
|
setUnreadGroups(prev => {
|
||||||
|
if (notif.groupId === activeGroupId) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(notif.groupId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNotifications(prev => [notif, ...prev]);
|
||||||
|
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('message:new', handleNewMsg);
|
||||||
|
socket.on('notification:new', handleNotification);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('message:new', handleNewMsg);
|
||||||
|
socket.off('notification:new', handleNotification);
|
||||||
|
};
|
||||||
|
}, [socket, toast]);
|
||||||
|
|
||||||
|
const selectGroup = (id) => {
|
||||||
|
setActiveGroupId(id);
|
||||||
|
if (isMobile) setShowSidebar(false);
|
||||||
|
// Clear notifications for this group
|
||||||
|
setNotifications(prev => prev.filter(n => n.groupId !== id));
|
||||||
|
setUnreadGroups(prev => { const next = new Set(prev); next.delete(id); return next; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeGroup = [
|
||||||
|
...(groups.publicGroups || []),
|
||||||
|
...(groups.privateGroups || [])
|
||||||
|
].find(g => g.id === activeGroupId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-layout">
|
||||||
|
{(!isMobile || showSidebar) && (
|
||||||
|
<Sidebar
|
||||||
|
groups={groups}
|
||||||
|
activeGroupId={activeGroupId}
|
||||||
|
onSelectGroup={selectGroup}
|
||||||
|
notifications={notifications}
|
||||||
|
unreadGroups={unreadGroups}
|
||||||
|
onNewChat={() => setModal('newchat')}
|
||||||
|
onProfile={() => setModal('profile')}
|
||||||
|
onUsers={() => setModal('users')}
|
||||||
|
onSettings={() => setModal('settings')}
|
||||||
|
onGroupsUpdated={loadGroups}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!isMobile || !showSidebar) && (
|
||||||
|
<ChatWindow
|
||||||
|
group={activeGroup}
|
||||||
|
onBack={isMobile ? () => setShowSidebar(true) : null}
|
||||||
|
onGroupUpdated={loadGroups}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||||
|
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
||||||
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
|
||||||
|
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/pages/Login.css
Normal file
106
frontend/src/pages/Login.css
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #e8f0fe 0%, #f1f3f4 50%, #e8f0fe 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-img {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 16px;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-logo svg {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #202124;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo p {
|
||||||
|
color: #5f6368;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-me {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #5f6368;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-me input[type="checkbox"] {
|
||||||
|
accent-color: #1a73e8;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #9aa0a6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.support-link:hover {
|
||||||
|
color: var(--primary-dark, #1557b0);
|
||||||
|
}
|
||||||
129
frontend/src/pages/Login.jsx
Normal file
129
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
|
import { useToast } from '../contexts/ToastContext.jsx';
|
||||||
|
import { api } from '../utils/api.js';
|
||||||
|
import './Login.css';
|
||||||
|
import SupportModal from '../components/SupportModal.jsx';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showSupport, setShowSupport] = useState(false);
|
||||||
|
const [settings, setSettings] = useState({});
|
||||||
|
const { login } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await login(email, password, rememberMe);
|
||||||
|
if (data.mustChangePassword) {
|
||||||
|
nav('/change-password');
|
||||||
|
} else {
|
||||||
|
nav('/');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message === 'suspended') {
|
||||||
|
toast(`Your account has been suspended. Contact: ${err.adminEmail || 'your admin'} for assistance.`, 'error', 8000);
|
||||||
|
} else {
|
||||||
|
toast(err.message || 'Login failed', 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle suspension error from API directly
|
||||||
|
const handleLoginError = async (email, password, rememberMe) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password, rememberMe })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
if (data.error === 'suspended') {
|
||||||
|
toast(`Your account has been suspended. Contact ${data.adminEmail || 'your administrator'} for assistance.`, 'error', 8000);
|
||||||
|
} else {
|
||||||
|
toast(data.error || 'Login failed', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Success handled by login function above
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const appName = settings.app_name || 'TeamChat';
|
||||||
|
const logoUrl = settings.logo_url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-logo">
|
||||||
|
{logoUrl ? (
|
||||||
|
<img src={logoUrl} alt={appName} className="logo-img" />
|
||||||
|
) : (
|
||||||
|
<div className="default-logo">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||||
|
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||||
|
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||||
|
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1>{appName}</h1>
|
||||||
|
<p>Sign in to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.pw_reset_active === 'true' && (
|
||||||
|
<div className="warning-banner" style={{ marginBottom: 16 }}>
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span><strong>PW_RESET is enabled.</strong> The admin password is being reset on each restart. Disable PW_RESET in your environment to stop this behavior.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
|
<div className="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input className="input" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus placeholder="your@email.com" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Password</label>
|
||||||
|
<input className="input" type="password" value={password} onChange={e => setPassword(e.target.value)} required placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="remember-me">
|
||||||
|
<input type="checkbox" checked={rememberMe} onChange={e => setRememberMe(e.target.checked)} />
|
||||||
|
<span>Remember me</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="btn btn-primary w-full" type="submit" disabled={loading}>
|
||||||
|
{loading ? <span className="spinner" style={{ width: 18, height: 18 }} /> : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="login-footer">
|
||||||
|
<button className="support-link" onClick={() => setShowSupport(true)}>
|
||||||
|
Need help? Contact Support
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSupport && <SupportModal onClose={() => setShowSupport(false)} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/utils/api.js
Normal file
98
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const BASE = '/api';
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req(method, path, body, opts = {}) {
|
||||||
|
const token = getToken();
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
let fetchOpts = { method, headers };
|
||||||
|
|
||||||
|
if (body instanceof FormData) {
|
||||||
|
fetchOpts.body = body;
|
||||||
|
} else if (body) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
fetchOpts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(BASE + path, fetchOpts);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
login: (body) => req('POST', '/auth/login', body),
|
||||||
|
submitSupport: (body) => req('POST', '/auth/support', body),
|
||||||
|
logout: () => req('POST', '/auth/logout'),
|
||||||
|
me: () => req('GET', '/auth/me'),
|
||||||
|
changePassword: (body) => req('POST', '/auth/change-password', body),
|
||||||
|
|
||||||
|
// Users
|
||||||
|
getUsers: () => req('GET', '/users'),
|
||||||
|
searchUsers: (q) => req('GET', `/users/search?q=${encodeURIComponent(q)}`),
|
||||||
|
createUser: (body) => req('POST', '/users', body),
|
||||||
|
bulkUsers: (users) => req('POST', '/users/bulk', { users }),
|
||||||
|
updateRole: (id, role) => req('PATCH', `/users/${id}/role`, { role }),
|
||||||
|
resetPassword: (id, password) => req('PATCH', `/users/${id}/reset-password`, { password }),
|
||||||
|
suspendUser: (id) => req('PATCH', `/users/${id}/suspend`),
|
||||||
|
activateUser: (id) => req('PATCH', `/users/${id}/activate`),
|
||||||
|
deleteUser: (id) => req('DELETE', `/users/${id}`),
|
||||||
|
updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag }
|
||||||
|
uploadAvatar: (file) => {
|
||||||
|
const form = new FormData(); form.append('avatar', file);
|
||||||
|
return req('POST', '/users/me/avatar', form);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
getGroups: () => req('GET', '/groups'),
|
||||||
|
createGroup: (body) => req('POST', '/groups', body),
|
||||||
|
renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }),
|
||||||
|
getMembers: (id) => req('GET', `/groups/${id}/members`),
|
||||||
|
addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }),
|
||||||
|
leaveGroup: (id) => req('DELETE', `/groups/${id}/leave`),
|
||||||
|
takeOwnership: (id) => req('POST', `/groups/${id}/take-ownership`),
|
||||||
|
deleteGroup: (id) => req('DELETE', `/groups/${id}`),
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
getMessages: (groupId, before) => req('GET', `/messages/group/${groupId}${before ? `?before=${before}` : ''}`),
|
||||||
|
sendMessage: (groupId, body) => req('POST', `/messages/group/${groupId}`, body),
|
||||||
|
uploadImage: (groupId, file, extra = {}) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('image', file);
|
||||||
|
if (extra.replyToId) form.append('replyToId', extra.replyToId);
|
||||||
|
if (extra.content) form.append('content', extra.content);
|
||||||
|
return req('POST', `/messages/group/${groupId}/image`, form);
|
||||||
|
},
|
||||||
|
deleteMessage: (id) => req('DELETE', `/messages/${id}`),
|
||||||
|
toggleReaction: (id, emoji) => req('POST', `/messages/${id}/reactions`, { emoji }),
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
getSettings: () => req('GET', '/settings'),
|
||||||
|
updateAppName: (name) => req('PATCH', '/settings/app-name', { name }),
|
||||||
|
uploadLogo: (file) => {
|
||||||
|
const form = new FormData(); form.append('logo', file);
|
||||||
|
return req('POST', '/settings/logo', form);
|
||||||
|
},
|
||||||
|
uploadIconNewChat: (file) => {
|
||||||
|
const form = new FormData(); form.append('icon', file);
|
||||||
|
return req('POST', '/settings/icon-newchat', form);
|
||||||
|
},
|
||||||
|
uploadIconGroupInfo: (file) => {
|
||||||
|
const form = new FormData(); form.append('icon', file);
|
||||||
|
return req('POST', '/settings/icon-groupinfo', form);
|
||||||
|
},
|
||||||
|
resetSettings: () => req('POST', '/settings/reset'),
|
||||||
|
|
||||||
|
// Push notifications
|
||||||
|
getPushKey: () => req('GET', '/push/vapid-public'),
|
||||||
|
subscribePush: (sub) => req('POST', '/push/subscribe', sub),
|
||||||
|
unsubscribePush: (endpoint) => req('POST', '/push/unsubscribe', { endpoint }),
|
||||||
|
|
||||||
|
// Link preview
|
||||||
|
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
|
||||||
|
};
|
||||||
25
frontend/vite.config.js
Normal file
25
frontend/vite.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
'socket': ['socket.io-client'],
|
||||||
|
'emoji': ['emoji-mart', '@emoji-mart/data', '@emoji-mart/react'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
'/uploads': 'http://localhost:3000',
|
||||||
|
'/socket.io': { target: 'http://localhost:3000', ws: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user