V0.9.23
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/png" href="/icons/jama.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#1a73e8" />
|
||||
<meta name="description" content="jama - just another messaging app" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>jama</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.23",
|
||||
"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",
|
||||
"marked": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.1.4"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 682 B |
BIN
frontend/public/icons/icon-192-maskable.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/icons/icon-512-maskable.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
frontend/public/icons/jama.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
frontend/public/icons/logo-64.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
38
frontend/public/manifest.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "jama",
|
||||
"short_name": "jama",
|
||||
"description": "Modern team messaging application",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#1a73e8",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"min_width": "320px"
|
||||
}
|
||||
109
frontend/public/sw.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const CACHE_NAME = 'jama-v1';
|
||||
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 scope
|
||||
let badgeCount = 0;
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
let data = {};
|
||||
try { data = event.data.json(); } catch (e) { return; }
|
||||
|
||||
badgeCount++;
|
||||
|
||||
// Update app badge
|
||||
if (self.navigator && self.navigator.setAppBadge) {
|
||||
self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||
}
|
||||
|
||||
// Check if app is currently visible — if so, skip the notification
|
||||
const showNotification = clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
}).then((clientList) => {
|
||||
const appVisible = clientList.some(
|
||||
(c) => c.visibilityState === 'visible'
|
||||
);
|
||||
// Still show if app is open but hidden (minimized), skip only if truly visible
|
||||
if (appVisible) return;
|
||||
|
||||
return self.registration.showNotification(data.title || 'New Message', {
|
||||
body: data.body || '',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192-maskable.png',
|
||||
data: { url: data.url || '/' },
|
||||
// Use unique tag per group so notifications group by conversation
|
||||
tag: data.groupId ? `jama-group-${data.groupId}` : 'jama-message',
|
||||
renotify: true,
|
||||
});
|
||||
});
|
||||
|
||||
event.waitUntil(showNotification);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
badgeCount = 0;
|
||||
if (self.navigator && self.navigator.clearAppBadge) {
|
||||
self.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 app signals it
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'CLEAR_BADGE') {
|
||||
badgeCount = 0;
|
||||
if (self.navigator && self.navigator.clearAppBadge) {
|
||||
self.navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
}
|
||||
if (event.data?.type === 'SET_BADGE') {
|
||||
badgeCount = event.data.count || 0;
|
||||
if (self.navigator && self.navigator.setAppBadge) {
|
||||
if (badgeCount > 0) {
|
||||
self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||
} else {
|
||||
self.navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
54
frontend/src/App.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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();
|
||||
// Always show login in light mode regardless of user's saved theme preference
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
if (loading) return null;
|
||||
if (user && !mustChangePassword) return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function RestoreTheme() {
|
||||
// Called when entering a protected route — restore the user's saved theme
|
||||
const saved = localStorage.getItem('jama-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
return null;
|
||||
}
|
||||
|
||||
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><RestoreTheme /><Chat /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/AboutModal.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
const CLAUDE_URL = 'https://claude.ai';
|
||||
|
||||
// Render "Built With" value — separator trails its token so it never starts a new line
|
||||
function BuiltWithValue({ value }) {
|
||||
if (!value) return null;
|
||||
const parts = value.split('·').map(s => s.trim());
|
||||
return (
|
||||
<span style={{ display: 'inline' }}>
|
||||
{parts.map((part, i) => (
|
||||
<span key={part} style={{ whiteSpace: 'nowrap' }}>
|
||||
{part === 'Claude.ai'
|
||||
? <a href={CLAUDE_URL} target="_blank" rel="noreferrer" className="about-link">{part}</a>
|
||||
: part}
|
||||
{i < parts.length - 1 && <span style={{ margin: '0 4px', color: 'var(--text-tertiary)' }}>·</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AboutModal({ onClose }) {
|
||||
const [about, setAbout] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/about')
|
||||
.then(r => r.json())
|
||||
.then(({ about }) => setAbout(about))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Always use the original app identity — not the user-customised settings name/logo
|
||||
const appName = about?.default_app_name || 'jama';
|
||||
const logoSrc = about?.default_logo || '/icons/jama.png';
|
||||
const version = about?.version || '';
|
||||
const a = about || {};
|
||||
|
||||
const rows = [
|
||||
{ label: 'Version', value: version },
|
||||
{ label: 'Built With', value: a.built_with, builtWith: true },
|
||||
{ label: 'Developer', value: a.developer },
|
||||
{ label: 'License', value: a.license, link: a.license_url },
|
||||
].filter(r => r.value);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal about-modal">
|
||||
<button className="btn-icon about-close" 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 className="about-hero">
|
||||
<img src={logoSrc} alt={appName} className="about-logo" />
|
||||
<h1 className="about-appname">{appName}</h1>
|
||||
<p className="about-tagline">just another messaging app</p>
|
||||
</div>
|
||||
|
||||
{about ? (
|
||||
<>
|
||||
<div className="about-table">
|
||||
{rows.map(({ label, value, builtWith, link }) => (
|
||||
<div className="about-row" key={label}>
|
||||
<span className="about-label">{label}</span>
|
||||
<span className="about-value">
|
||||
{builtWith
|
||||
? <BuiltWithValue value={value} />
|
||||
: link
|
||||
? <a href={link} target="_blank" rel="noreferrer" className="about-link">{value}</a>
|
||||
: value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{a.description && <p className="about-footer">{a.description}</p>}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-center" style={{ padding: 24 }}><div className="spinner" /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
559
frontend/src/components/BrandingModal.jsx
Normal file
@@ -0,0 +1,559 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
const DEFAULT_TITLE_COLOR = '#1a73e8'; // light mode default
|
||||
const DEFAULT_TITLE_DARK_COLOR = '#60a5fa'; // dark mode default (lighter blue readable on dark bg)
|
||||
const DEFAULT_PUBLIC_COLOR = '#1a73e8';
|
||||
const DEFAULT_DM_COLOR = '#a142f4';
|
||||
|
||||
const COLOUR_SUGGESTIONS = [
|
||||
'#1a73e8', '#a142f4', '#e53935', '#fa7b17', '#fdd835', '#34a853',
|
||||
];
|
||||
|
||||
// ── Title Colour Row — one row per mode ──────────────────────────────────────
|
||||
|
||||
function TitleColourRow({ bgColor, bgLabel, textColor, onChange }) {
|
||||
const [mode, setMode] = useState('idle'); // 'idle' | 'custom'
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{/* Preview box */}
|
||||
<div style={{
|
||||
background: bgColor, borderRadius: 8, padding: '0 14px',
|
||||
height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: '1px solid var(--border)', minWidth: 110, flexShrink: 0,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<span style={{ color: textColor, fontWeight: 700, fontSize: 16 }}>
|
||||
Title
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mode === 'idle' && (
|
||||
<>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', fontFamily: 'monospace', minWidth: 64 }}>{textColor}</span>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setMode('custom')}>Custom</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'custom' && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<CustomPicker
|
||||
initial={textColor}
|
||||
onSet={(hex) => { onChange(hex); setMode('idle'); }}
|
||||
onBack={() => setMode('idle')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Colour math helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function hexToHsv(hex) {
|
||||
const r = parseInt(hex.slice(1,3),16)/255;
|
||||
const g = parseInt(hex.slice(3,5),16)/255;
|
||||
const b = parseInt(hex.slice(5,7),16)/255;
|
||||
const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max - min;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
return { h: h * 360, s: max === 0 ? 0 : d / max, v: max };
|
||||
}
|
||||
|
||||
function hsvToHex(h, s, v) {
|
||||
h = h / 360;
|
||||
const i = Math.floor(h * 6);
|
||||
const f = h * 6 - i;
|
||||
const p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
|
||||
let r, g, b;
|
||||
switch (i % 6) {
|
||||
case 0: r=v; g=t; b=p; break; case 1: r=q; g=v; b=p; break;
|
||||
case 2: r=p; g=v; b=t; break; case 3: r=p; g=q; b=v; break;
|
||||
case 4: r=t; g=p; b=v; break; default: r=v; g=p; b=q;
|
||||
}
|
||||
return '#' + [r,g,b].map(x => Math.round(x*255).toString(16).padStart(2,'0')).join('');
|
||||
}
|
||||
|
||||
function isValidHex(h) { return /^#[0-9a-fA-F]{6}$/.test(h); }
|
||||
|
||||
// ── SV (saturation/value) square ─────────────────────────────────────────────
|
||||
|
||||
function SvSquare({ hue, s, v, onChange }) {
|
||||
const canvasRef = useRef(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width, H = canvas.height;
|
||||
// White → hue gradient (left→right)
|
||||
const hGrad = ctx.createLinearGradient(0, 0, W, 0);
|
||||
hGrad.addColorStop(0, '#fff');
|
||||
hGrad.addColorStop(1, `hsl(${hue},100%,50%)`);
|
||||
ctx.fillStyle = hGrad; ctx.fillRect(0, 0, W, H);
|
||||
// Transparent → black gradient (top→bottom)
|
||||
const vGrad = ctx.createLinearGradient(0, 0, 0, H);
|
||||
vGrad.addColorStop(0, 'transparent');
|
||||
vGrad.addColorStop(1, '#000');
|
||||
ctx.fillStyle = vGrad; ctx.fillRect(0, 0, W, H);
|
||||
}, [hue]);
|
||||
|
||||
const getPos = (e, canvas) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
|
||||
const cy = (e.touches ? e.touches[0].clientY : e.clientY) - r.top;
|
||||
return {
|
||||
s: Math.max(0, Math.min(1, cx / r.width)),
|
||||
v: Math.max(0, Math.min(1, 1 - cy / r.height)),
|
||||
};
|
||||
};
|
||||
|
||||
const handle = (e) => {
|
||||
e.preventDefault();
|
||||
const p = getPos(e, canvasRef.current);
|
||||
onChange(p.s, p.v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none' }}>
|
||||
<canvas
|
||||
ref={canvasRef} width={260} height={160}
|
||||
style={{ display: 'block', width: '100%', height: 160, borderRadius: 8, cursor: 'crosshair', border: '1px solid var(--border)' }}
|
||||
onMouseDown={e => { dragging.current = true; handle(e); }}
|
||||
onMouseMove={e => { if (dragging.current) handle(e); }}
|
||||
onMouseUp={() => { dragging.current = false; }}
|
||||
onMouseLeave={() => { dragging.current = false; }}
|
||||
onTouchStart={handle} onTouchMove={handle}
|
||||
/>
|
||||
{/* Cursor circle */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `calc(${s * 100}% - 7px)`,
|
||||
top: `calc(${(1 - v) * 100}% - 7px)`,
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.4)',
|
||||
pointerEvents: 'none',
|
||||
background: 'transparent',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hue bar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function HueBar({ hue, onChange }) {
|
||||
const barRef = useRef(null);
|
||||
const dragging = useRef(false);
|
||||
|
||||
const handle = (e) => {
|
||||
e.preventDefault();
|
||||
const r = barRef.current.getBoundingClientRect();
|
||||
const cx = (e.touches ? e.touches[0].clientX : e.clientX) - r.left;
|
||||
onChange(Math.max(0, Math.min(360, (cx / r.width) * 360)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', userSelect: 'none', touchAction: 'none', marginTop: 10 }}>
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
height: 20, borderRadius: 10,
|
||||
background: 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)',
|
||||
border: '1px solid var(--border)', cursor: 'pointer',
|
||||
}}
|
||||
onMouseDown={e => { dragging.current = true; handle(e); }}
|
||||
onMouseMove={e => { if (dragging.current) handle(e); }}
|
||||
onMouseUp={() => { dragging.current = false; }}
|
||||
onMouseLeave={() => { dragging.current = false; }}
|
||||
onTouchStart={handle} onTouchMove={handle}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `calc(${(hue / 360) * 100}% - 9px)`,
|
||||
top: -2, width: 18, height: 24, borderRadius: 4,
|
||||
background: `hsl(${hue},100%,50%)`,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 0 0 1.5px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom HSV picker ─────────────────────────────────────────────────────────
|
||||
|
||||
function CustomPicker({ initial, onSet, onBack }) {
|
||||
const { h: ih, s: is, v: iv } = hexToHsv(initial);
|
||||
const [hue, setHue] = useState(ih);
|
||||
const [sat, setSat] = useState(is);
|
||||
const [val, setVal] = useState(iv);
|
||||
const [hexInput, setHexInput] = useState(initial);
|
||||
const [hexError, setHexError] = useState(false);
|
||||
|
||||
const current = hsvToHex(hue, sat, val);
|
||||
|
||||
// Sync hex input when sliders change
|
||||
useEffect(() => { setHexInput(current); setHexError(false); }, [current]);
|
||||
|
||||
const handleHexInput = (e) => {
|
||||
const v = e.target.value;
|
||||
setHexInput(v);
|
||||
if (isValidHex(v)) {
|
||||
const { h, s, v: bv } = hexToHsv(v);
|
||||
setHue(h); setSat(s); setVal(bv);
|
||||
setHexError(false);
|
||||
} else {
|
||||
setHexError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<SvSquare hue={hue} s={sat} v={val} onChange={(s, v) => { setSat(s); setVal(v); }} />
|
||||
<HueBar hue={hue} onChange={setHue} />
|
||||
|
||||
{/* Preview + hex input */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 8, background: current,
|
||||
border: '2px solid var(--border)', flexShrink: 0,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.15)',
|
||||
}} />
|
||||
<input
|
||||
value={hexInput}
|
||||
onChange={handleHexInput}
|
||||
maxLength={7}
|
||||
style={{
|
||||
fontFamily: 'monospace', fontSize: 14,
|
||||
padding: '6px 10px', borderRadius: 8,
|
||||
border: `1px solid ${hexError ? '#e53935' : 'var(--border)'}`,
|
||||
width: 110, background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
placeholder="#000000"
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Chosen colour</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 2 }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => onSet(current)} disabled={hexError}>
|
||||
Set
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onBack}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ColourPicker card ─────────────────────────────────────────────────────────
|
||||
|
||||
function ColourPicker({ label, value, onChange, preview }) {
|
||||
const [mode, setMode] = useState('suggestions'); // 'suggestions' | 'custom'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="settings-section-label">{label}</div>
|
||||
|
||||
{/* Current colour preview */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
{preview
|
||||
? preview(value)
|
||||
: <div style={{ width: 36, height: 36, borderRadius: 8, background: value, border: '2px solid var(--border)', flexShrink: 0 }} />
|
||||
}
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{value}</span>
|
||||
</div>
|
||||
|
||||
{mode === 'suggestions' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
{COLOUR_SUGGESTIONS.map(hex => (
|
||||
<button
|
||||
key={hex}
|
||||
onClick={() => onChange(hex)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: hex, border: hex === value ? '3px solid var(--text-primary)' : '2px solid var(--border)',
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
boxShadow: hex === value ? '0 0 0 2px var(--surface), 0 0 0 4px var(--text-primary)' : 'none',
|
||||
transition: 'box-shadow 0.15s',
|
||||
}}
|
||||
title={hex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setMode('custom')}>
|
||||
Custom
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'custom' && (
|
||||
<CustomPicker
|
||||
initial={value}
|
||||
onSet={(hex) => { onChange(hex); setMode('suggestions'); }}
|
||||
onBack={() => setMode('suggestions')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BrandingModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [tab, setTab] = useState('general'); // 'general' | 'colours'
|
||||
const [settings, setSettings] = useState({});
|
||||
const [appName, setAppName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
const [colourTitle, setColourTitle] = useState(DEFAULT_TITLE_COLOR);
|
||||
const [colourTitleDark, setColourTitleDark] = useState(DEFAULT_TITLE_DARK_COLOR);
|
||||
const [colourPublic, setColourPublic] = useState(DEFAULT_PUBLIC_COLOR);
|
||||
const [colourDm, setColourDm] = useState(DEFAULT_DM_COLOR);
|
||||
const [savingColours, setSavingColours] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setSettings(settings);
|
||||
setAppName(settings.app_name || 'jama');
|
||||
setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR);
|
||||
setColourTitleDark(settings.color_title_dark || DEFAULT_TITLE_DARK_COLOR);
|
||||
setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
|
||||
setColourDm(settings.color_avatar_dm || DEFAULT_DM_COLOR);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama: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 handleSaveColours = async () => {
|
||||
setSavingColours(true);
|
||||
try {
|
||||
await api.updateColors({
|
||||
colorTitle: colourTitle,
|
||||
colorTitleDark: colourTitleDark,
|
||||
colorAvatarPublic: colourPublic,
|
||||
colorAvatarDm: colourDm,
|
||||
});
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
color_title: colourTitle,
|
||||
color_title_dark: colourTitleDark,
|
||||
color_avatar_public: colourPublic,
|
||||
color_avatar_dm: colourDm,
|
||||
}));
|
||||
toast('Colours updated', 'success');
|
||||
notifySidebarRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setSavingColours(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.resetSettings();
|
||||
const { settings: fresh } = await api.getSettings();
|
||||
setSettings(fresh);
|
||||
setAppName(fresh.app_name || 'jama');
|
||||
setColourTitle(DEFAULT_TITLE_COLOR);
|
||||
setColourTitleDark(DEFAULT_TITLE_DARK_COLOR);
|
||||
setColourPublic(DEFAULT_PUBLIC_COLOR);
|
||||
setColourDm(DEFAULT_DM_COLOR);
|
||||
toast('Settings reset to defaults', 'success');
|
||||
notifySidebarRefresh();
|
||||
setShowResetConfirm(false);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 }}>Branding</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: 24 }}>
|
||||
<button className={`btn btn-sm ${tab === 'general' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('general')}>General</button>
|
||||
<button className={`btn btn-sm ${tab === 'colours' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('colours')}>Colours</button>
|
||||
</div>
|
||||
|
||||
{tab === 'general' && (
|
||||
<>
|
||||
{/* 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
|
||||
}}>
|
||||
<img src={settings.logo_url || '/icons/jama.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
</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>
|
||||
|
||||
{/* Reset */}
|
||||
<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 colours 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>
|
||||
</div>
|
||||
|
||||
{settings.pw_reset_active === 'true' && (
|
||||
<div className="warning-banner">
|
||||
<span>⚠️</span>
|
||||
<span><strong>ADMPW_RESET is active.</strong> The default admin password is being reset on every restart. Set ADMPW_RESET=false in your environment variables to stop this.</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'colours' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div>
|
||||
<div className="settings-section-label">App Title Colour</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 4 }}>
|
||||
<TitleColourRow
|
||||
bgColor="#f1f3f4"
|
||||
bgLabel="Light mode"
|
||||
textColor={colourTitle}
|
||||
onChange={setColourTitle}
|
||||
/>
|
||||
<TitleColourRow
|
||||
bgColor="#13131f"
|
||||
bgLabel="Dark mode"
|
||||
textColor={colourTitleDark}
|
||||
onChange={setColourTitleDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
|
||||
<ColourPicker
|
||||
label="Public Message Avatar Colour"
|
||||
value={colourPublic}
|
||||
onChange={setColourPublic}
|
||||
preview={(val) => (
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: '50%', background: val,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
|
||||
}}>A</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
|
||||
<ColourPicker
|
||||
label="Direct Message Avatar Colour"
|
||||
value={colourDm}
|
||||
onChange={setColourDm}
|
||||
preview={(val) => (
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: '50%', background: val,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'white', fontWeight: 700, fontSize: 15, flexShrink: 0,
|
||||
}}>B</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 20 }}>
|
||||
<button className="btn btn-primary" onClick={handleSaveColours} disabled={savingColours}>
|
||||
{savingColours ? 'Saving...' : 'Save Colours'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
frontend/src/components/ChatWindow.css
Normal file
@@ -0,0 +1,164 @@
|
||||
.chat-window {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-variant);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Real name in brackets in DM header */
|
||||
.chat-header-real-name {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.readonly-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
min-height: 0; /* critical: allows flex child to shrink below content size */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
scroll-padding-bottom: 0;
|
||||
overscroll-behavior: contain;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Cap message width and centre on wide screens */
|
||||
.messages-container > * {
|
||||
max-width: 1024px;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
319
frontend/src/components/ChatWindow.jsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Message from './Message.jsx';
|
||||
import MessageInput from './MessageInput.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import './ChatWindow.css';
|
||||
import GroupInfoModal from './GroupInfoModal.jsx';
|
||||
|
||||
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onlineUserIds = new Set() }) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { socket } = useSocket();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [typing, setTyping] = useState([]);
|
||||
const [iconGroupInfo, setIconGroupInfo] = useState('');
|
||||
const [avatarColors, setAvatarColors] = useState({ public: '#1a73e8', dm: '#a142f4' });
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState(null);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesContainerRef = useRef(null);
|
||||
const typingTimers = useRef({});
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
|
||||
}).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => {
|
||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
|
||||
}).catch(() => {});
|
||||
window.addEventListener('jama:settings-updated', handler);
|
||||
window.addEventListener('jama:settings-changed', handler);
|
||||
return () => {
|
||||
window.removeEventListener('jama:settings-updated', handler);
|
||||
window.removeEventListener('jama: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.map(m =>
|
||||
m.id === messageId ? { ...m, is_deleted: 1, content: null, image_url: null } : m
|
||||
));
|
||||
};
|
||||
|
||||
const handleReaction = ({ messageId, reactions }) => {
|
||||
setMessages(prev => prev.map(m =>
|
||||
m.id === messageId ? { ...m, reactions } : m
|
||||
));
|
||||
};
|
||||
|
||||
const handleTypingStart = ({ userId: tid, user: tu }) => {
|
||||
if (tid === currentUser?.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));
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const handleTypingStop = ({ userId: tid }) => {
|
||||
clearTimeout(typingTimers.current[tid]);
|
||||
setTyping(prev => prev.filter(t => t.userId !== tid));
|
||||
};
|
||||
|
||||
const handleGroupUpdated = (updatedGroup) => {
|
||||
if (updatedGroup.id === group.id) onGroupUpdated?.();
|
||||
};
|
||||
|
||||
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);
|
||||
socket.on('group:updated', handleGroupUpdated);
|
||||
|
||||
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.off('group:updated', handleGroupUpdated);
|
||||
};
|
||||
}, [socket, group?.id, currentUser?.id]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (!hasMore || loading || messages.length === 0) return;
|
||||
const container = messagesContainerRef.current;
|
||||
const prevScrollHeight = container?.scrollHeight || 0;
|
||||
setLoading(true);
|
||||
try {
|
||||
const oldest = messages[0];
|
||||
const { messages: older } = await api.getMessages(group.id, oldest.id);
|
||||
setMessages(prev => [...older, ...prev]);
|
||||
setHasMore(older.length >= 50);
|
||||
requestAnimationFrame(() => {
|
||||
if (container) container.scrollTop = container.scrollHeight - prevScrollHeight;
|
||||
});
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async ({ content, imageFile, linkPreview, emojiOnly }) => {
|
||||
if ((!content?.trim() && !imageFile) || !group) return;
|
||||
const replyToId = replyTo?.id || null;
|
||||
setReplyTo(null);
|
||||
try {
|
||||
if (imageFile) {
|
||||
await api.uploadImage(group.id, imageFile, { replyToId, content: content?.trim() || '' });
|
||||
} else {
|
||||
await api.sendMessage(group.id, { content: content.trim(), replyToId, linkPreview, emojiOnly });
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message || 'Failed to send', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (msgId) => {
|
||||
try {
|
||||
await api.deleteMessage(msgId);
|
||||
} catch (e) {
|
||||
toast(e.message || 'Could not delete', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReact = async (msgId, emoji) => {
|
||||
try {
|
||||
await api.toggleReaction(msgId, emoji);
|
||||
} catch (e) {
|
||||
toast(e.message || 'Could not react', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = (msg) => {
|
||||
setReplyTo(msg);
|
||||
};
|
||||
|
||||
const handleDirectMessage = (dmGroup) => {
|
||||
onDirectMessage?.(dmGroup);
|
||||
};
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<div className="chat-window empty">
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<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>
|
||||
</div>
|
||||
<h3>Select a conversation</h3>
|
||||
<p>Choose a channel or direct message to start chatting</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isDirect = !!group.is_direct;
|
||||
const peerName = group.peer_display_name
|
||||
? <>{group.peer_display_name}<span className="chat-header-real-name"> ({group.peer_real_name})</span></>
|
||||
: group.peer_real_name || group.name;
|
||||
const isOnline = isDirect && group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-window">
|
||||
{/* Header */}
|
||||
<div className="chat-header">
|
||||
{isMobile && onBack && (
|
||||
<button className="btn-icon" onClick={onBack} style={{ marginRight: 4 }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{isDirect && group.peer_avatar ? (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<img src={group.peer_avatar} alt={group.name} className="group-icon-sm" style={{ objectFit: 'cover', padding: 0 }} />
|
||||
{isOnline && <span className="online-dot" style={{ position: 'absolute', bottom: 1, right: 1 }} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="group-icon-sm" style={{ background: group.type === 'public' ? avatarColors.public : avatarColors.dm, flexShrink: 0 }}>
|
||||
{group.type === 'public' ? '#' : isDirect ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="chat-header-name truncate">
|
||||
{isDirect ? peerName : group.name}
|
||||
{group.is_readonly ? <span className="readonly-badge" style={{ marginLeft: 8 }}>read-only</span> : null}
|
||||
</div>
|
||||
{isDirect && isOnline && <div className="chat-header-sub" style={{ color: 'var(--success)' }}>Online</div>}
|
||||
{!isDirect && group.type === 'private' && <div className="chat-header-sub">Private group</div>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setShowInfo(true)}
|
||||
title="Conversation info"
|
||||
>
|
||||
{iconGroupInfo ? (
|
||||
<img src={iconGroupInfo} alt="info" style={{ width: 22, height: 22, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="22" height="22">
|
||||
<path strokeLinecap="round" strokeLinejoin="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={messagesContainerRef}>
|
||||
{hasMore && (
|
||||
<button className="load-more-btn" onClick={handleLoadMore} disabled={loading}>
|
||||
{loading ? 'Loading…' : 'Load older messages'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<Message
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
prevMessage={messages[i - 1]}
|
||||
currentUser={currentUser}
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
onReact={handleReact}
|
||||
onDirectMessage={handleDirectMessage}
|
||||
isDirect={isDirect}
|
||||
onlineUserIds={onlineUserIds}
|
||||
/>
|
||||
))}
|
||||
|
||||
{typing.length > 0 && (
|
||||
<div className="typing-indicator">
|
||||
<span>{typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing</span>
|
||||
<div className="dots"><span /><span /><span /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{group.is_readonly && currentUser?.role !== 'admin' ? (
|
||||
<div className="readonly-bar">
|
||||
<svg width="16" height="16" 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>
|
||||
This channel is read-only
|
||||
</div>
|
||||
) : (
|
||||
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} />
|
||||
)}
|
||||
</div>
|
||||
{showInfo && (
|
||||
<GroupInfoModal
|
||||
group={group}
|
||||
onClose={() => setShowInfo(false)}
|
||||
onUpdated={(updatedGroup) => { setShowInfo(false); onGroupUpdated && onGroupUpdated(updatedGroup); }}
|
||||
onBack={() => setShowInfo(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/GlobalBar.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export default function GlobalBar({ isMobile, showSidebar }) {
|
||||
const { connected } = useSocket();
|
||||
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
|
||||
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
window.addEventListener('jama:settings-changed', handler);
|
||||
// Re-render when theme changes so title colour switches correctly
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
});
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
return () => {
|
||||
window.removeEventListener('jama:settings-changed', handler);
|
||||
themeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const appName = settings.app_name || 'jama';
|
||||
const logoUrl = settings.logo_url;
|
||||
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null;
|
||||
|
||||
// On mobile: show bar only when sidebar is visible (chat list view)
|
||||
// On desktop: always show
|
||||
if (isMobile && !showSidebar) return null;
|
||||
|
||||
return (
|
||||
<div className="global-bar">
|
||||
<div className="global-bar-brand">
|
||||
<img
|
||||
src={logoUrl || '/icons/jama.png'}
|
||||
alt={appName}
|
||||
className="global-bar-logo"
|
||||
/>
|
||||
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
|
||||
</div>
|
||||
{!connected && (
|
||||
<span className="global-bar-offline" title="Offline">
|
||||
<span className="offline-dot" />
|
||||
<span className="offline-label">Offline</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
frontend/src/components/GroupInfoModal.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
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, onBack }) {
|
||||
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 [customName, setCustomName] = useState(group.owner_name_original ? group.name : '');
|
||||
const [savedCustomName, setSavedCustomName] = useState(group.owner_name_original ? group.name : '');
|
||||
const [savingCustom, setSavingCustom] = useState(false);
|
||||
|
||||
const isDirect = !!group.is_direct;
|
||||
const isOwner = group.owner_id === user.id;
|
||||
const isAdmin = user.role === 'admin';
|
||||
const canManage = !isDirect && ((group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin));
|
||||
const canRename = !isDirect && !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]);
|
||||
|
||||
const handleCustomName = async () => {
|
||||
setSavingCustom(true);
|
||||
try {
|
||||
const saved = customName.trim();
|
||||
await api.setCustomGroupName(group.id, saved);
|
||||
setSavedCustomName(saved);
|
||||
toast(saved ? 'Custom name saved' : 'Custom name removed', 'success');
|
||||
onUpdated();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setSavingCustom(false);
|
||||
}
|
||||
};
|
||||
|
||||
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('Renamed', 'success');
|
||||
onUpdated();
|
||||
setEditing(false);
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!confirm('Leave this message?')) return;
|
||||
try {
|
||||
await api.leaveGroup(group.id);
|
||||
toast('Left message', 'success');
|
||||
onClose();
|
||||
if (isDirect) {
|
||||
// For direct messages: socket group:deleted fired by server handles
|
||||
// removing from sidebar and clearing active group — no manual refresh needed
|
||||
} else {
|
||||
onUpdated();
|
||||
if (onBack) onBack();
|
||||
}
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleTakeOwnership = async () => {
|
||||
if (!confirm('Take ownership of this private group?')) 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.name} added`, 'success');
|
||||
api.getMembers(group.id).then(({ members }) => setMembers(members));
|
||||
setAddSearch('');
|
||||
setAddResults([]);
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleRemove = async (member) => {
|
||||
if (!confirm(`Remove ${member.name}?`)) return;
|
||||
try {
|
||||
await api.removeMember(group.id, member.id);
|
||||
toast(`${member.name} removed`, 'success');
|
||||
setMembers(prev => prev.filter(m => m.id !== member.id));
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Delete this message? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.deleteGroup(group.id);
|
||||
toast('Deleted', 'success');
|
||||
onUpdated();
|
||||
onClose();
|
||||
if (onBack) onBack();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
// For direct messages: only show Delete button (owner = remaining user after other left)
|
||||
const canDeleteDirect = isDirect && isOwner;
|
||||
const canDeleteRegular = !isDirect && (isOwner || (isAdmin && group.type === 'public')) && !group.is_default;
|
||||
|
||||
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 }}>Message 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)} 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)' }}>
|
||||
{isDirect ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
|
||||
</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>
|
||||
|
||||
{/* Custom name — any user can set their own display name for this group */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 6 }}>
|
||||
Your custom name <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(only visible to you)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={customName}
|
||||
onChange={e => setCustomName(e.target.value)}
|
||||
placeholder={group.owner_name_original || group.name}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCustomName()}
|
||||
/>
|
||||
{customName.trim() !== savedCustomName ? (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleCustomName} disabled={savingCustom}>
|
||||
Save
|
||||
</button>
|
||||
) : savedCustomName ? (
|
||||
<button className="btn btn-sm" style={{ background: 'var(--surface-variant)', color: 'var(--text-secondary)' }}
|
||||
onClick={() => { setCustomName(''); }}
|
||||
disabled={savingCustom}>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{group.owner_name_original && (
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||||
Showing as: <strong>{customName.trim() || group.owner_name_original}</strong>
|
||||
{customName.trim() && <span> ({group.owner_name_original})</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members — shown for private non-direct groups */}
|
||||
{group.type === 'private' && !isDirect && (
|
||||
<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" style={{ gap: 10, padding: '6px 0' }}>
|
||||
<Avatar user={m} size="sm" />
|
||||
<span className="flex-1 text-sm">{m.name}</span>
|
||||
{m.id === group.owner_id && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Owner</span>}
|
||||
{canManage && m.id !== group.owner_id && (
|
||||
<button
|
||||
onClick={() => handleRemove(m)}
|
||||
title="Remove"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4, lineHeight: 1, transition: 'color var(--transition)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'}
|
||||
>
|
||||
<svg width="14" height="14" 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>
|
||||
)}
|
||||
</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', background: 'var(--surface)' }}>
|
||||
{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)', color: 'var(--text-primary)' }} 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.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex-col gap-2">
|
||||
{/* Direct message: leave (if not already owner/last person) */}
|
||||
{isDirect && !isOwner && (
|
||||
<button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Conversation</button>
|
||||
)}
|
||||
{/* Regular private: leave if not owner */}
|
||||
{!isDirect && group.type === 'private' && !isOwner && (
|
||||
<button className="btn btn-secondary w-full" onClick={handleLeave}>Leave Group</button>
|
||||
)}
|
||||
{/* Admin take ownership (non-direct only) */}
|
||||
{!isDirect && isAdmin && group.type === 'private' && !isOwner && (
|
||||
<button className="btn btn-secondary w-full" onClick={handleTakeOwnership}>Take Ownership (Admin)</button>
|
||||
)}
|
||||
{/* Delete */}
|
||||
{(canDeleteDirect || canDeleteRegular) && (
|
||||
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/HelpModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
export default function HelpModal({ onClose, dismissed: initialDismissed }) {
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dismissed, setDismissed] = useState(!!initialDismissed);
|
||||
|
||||
useEffect(() => {
|
||||
api.getHelp()
|
||||
.then(({ content }) => setContent(content))
|
||||
.catch(() => setContent('# Getting Started\n\nHelp content could not be loaded.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleDismissToggle = async (e) => {
|
||||
const val = e.target.checked;
|
||||
setDismissed(val);
|
||||
try {
|
||||
await api.dismissHelp(val);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal help-modal">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 16 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Getting Started</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>
|
||||
|
||||
{/* Scrollable markdown content */}
|
||||
<div className="help-content">
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-tertiary)' }}>Loading…</div>
|
||||
) : (
|
||||
<div
|
||||
className="help-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(content) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="help-footer">
|
||||
<label className="flex items-center gap-2 text-sm" style={{ cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dismissed}
|
||||
onChange={handleDismissToggle}
|
||||
/>
|
||||
Do not show again at login
|
||||
</label>
|
||||
<button className="btn btn-primary btn-sm" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
frontend/src/components/ImageLightbox.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export default function ImageLightbox({ src, onClose }) {
|
||||
const overlayRef = useRef(null);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
// Prevent body scroll while open
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={(e) => e.target === overlayRef.current && onClose()}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
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: 10000,
|
||||
}}
|
||||
title="Close"
|
||||
>
|
||||
<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: 10000, 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
|
||||
src={src}
|
||||
alt="Full size"
|
||||
style={{
|
||||
maxWidth: '92vw',
|
||||
maxHeight: '92vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 8,
|
||||
userSelect: 'none',
|
||||
touchAction: 'pinch-zoom',
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.6)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
336
frontend/src/components/Message.css
Normal file
@@ -0,0 +1,336 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
margin: 6px 0;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .system-message {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.msg-link {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
word-break: break-all;
|
||||
}
|
||||
.msg-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Own bubble (primary background) — link must be white */
|
||||
.msg-bubble.out .msg-link {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.msg-bubble.out .msg-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Incoming bubble — link should be a dark/contrasting tone, not the same blue as bubble */
|
||||
.msg-bubble.in .msg-link {
|
||||
color: var(--primary-dark, #1565c0);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-wrapper.own { flex-direction: row-reverse; }
|
||||
.message-wrapper.grouped { margin-top: 2px; }
|
||||
.message-wrapper:not(.grouped) { margin-top: 10px; }
|
||||
|
||||
.avatar-spacer { width: 32px; flex-shrink: 0; }
|
||||
|
||||
.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: calc(0.75rem * var(--font-scale));
|
||||
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: calc(0.6875rem * var(--font-scale)); font-weight: 600; color: var(--primary); }
|
||||
.reply-text { font-size: calc(0.75rem * var(--font-scale)); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
|
||||
|
||||
/* Bubble row */
|
||||
.msg-bubble-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.own .msg-bubble-wrap {
|
||||
position: relative; 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;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.msg-bubble {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-bubble.out {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.msg-bubble.in {
|
||||
background: var(--bubble-in);
|
||||
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: calc(0.8125rem * var(--font-scale)); color: var(--text-tertiary); font-style: italic; }
|
||||
|
||||
.msg-text {
|
||||
font-size: calc(0.875rem * var(--font-scale));
|
||||
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;
|
||||
}
|
||||
|
||||
/* Sender bubble — primary colour is the background, so mention must contrast against it */
|
||||
.out .mention {
|
||||
color: #ffffff;
|
||||
background: rgba(255,255,255,0.22);
|
||||
}
|
||||
|
||||
.msg-image {
|
||||
max-width: 240px;
|
||||
max-height: 240px;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: calc(0.6875rem * var(--font-scale));
|
||||
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: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
font-size: calc(0.875rem * var(--font-scale));
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.reaction-count { font-size: calc(0.75rem * var(--font-scale)); 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); }
|
||||
|
||||
/* Emoji-only messages: no bubble background, large size */
|
||||
.msg-bubble.emoji-only {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.msg-bubble.emoji-only::after { display: none; }
|
||||
|
||||
.msg-text.emoji-msg {
|
||||
font-size: 3em;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
344
frontend/src/components/Message.jsx
Normal file
@@ -0,0 +1,344 @@
|
||||
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 { parseTS } from '../utils/api.js';
|
||||
import './Message.css';
|
||||
|
||||
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
||||
|
||||
function formatMsgContent(content) {
|
||||
if (!content) return '';
|
||||
// First handle @mentions
|
||||
let html = content.replace(/@\[([^\]]+)\]/g, (_, name) => `<span class="mention">@${name}</span>`);
|
||||
// Then linkify bare URLs (not already inside a tag)
|
||||
html = html.replace(/(https?:\/\/[^\s<>"]+)/g, (url) => {
|
||||
// Trim trailing punctuation that's unlikely to be part of the URL
|
||||
const trimmed = url.replace(/[.,!?;:)\]]+$/, '');
|
||||
const trailing = url.slice(trimmed.length);
|
||||
return `<a href="${trimmed}" target="_blank" rel="noopener noreferrer" class="msg-link">${trimmed}</a>${trailing}`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
// Detect emoji-only messages for large rendering
|
||||
function isEmojiOnly(str) {
|
||||
if (!str || str.length > 12) return false;
|
||||
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u;
|
||||
return emojiRegex.test(str.trim());
|
||||
}
|
||||
|
||||
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact, onDirectMessage, isDirect, onlineUserIds = new Set() }) {
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [showOptionsMenu, setShowOptionsMenu] = useState(false);
|
||||
const longPressTimer = useRef(null);
|
||||
const optionsMenuRef = useRef(null);
|
||||
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;
|
||||
const isSystem = msg.type === 'system';
|
||||
|
||||
// These must be computed before any early returns that reference them
|
||||
const showDateSep = !prevMessage ||
|
||||
parseTS(msg.created_at).toDateString() !== parseTS(prevMessage.created_at).toDateString();
|
||||
|
||||
const prevSameUser = !showDateSep && prevMessage &&
|
||||
prevMessage.user_id === msg.user_id &&
|
||||
prevMessage.type !== 'system' && msg.type !== 'system';
|
||||
|
||||
const canDelete = !msg.is_deleted && (
|
||||
msg.user_id === currentUser.id ||
|
||||
currentUser.role === 'admin' ||
|
||||
msg.group_owner_id === currentUser.id
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Close options menu on outside click
|
||||
useEffect(() => {
|
||||
if (!showOptionsMenu) return;
|
||||
const close = (e) => {
|
||||
if (optionsMenuRef.current && !optionsMenuRef.current.contains(e.target)) {
|
||||
setShowOptionsMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
document.addEventListener('touchstart', close);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close);
|
||||
document.removeEventListener('touchstart', close);
|
||||
};
|
||||
}, [showOptionsMenu]);
|
||||
|
||||
const handleReact = (emoji) => {
|
||||
onReact(msg.id, emoji);
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!msg.content) return;
|
||||
navigator.clipboard.writeText(msg.content).catch(() => {});
|
||||
};
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showEmojiPicker && wrapperRef.current) {
|
||||
const rect = wrapperRef.current.getBoundingClientRect();
|
||||
setPickerOpensDown(rect.top < 400);
|
||||
}
|
||||
setShowEmojiPicker(p => !p);
|
||||
};
|
||||
|
||||
// Long press for mobile action menu (DMs only)
|
||||
const handleTouchStart = () => {
|
||||
if (!isDirect) return;
|
||||
longPressTimer.current = setTimeout(() => setShowOptionsMenu(true), 500);
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
if (longPressTimer.current) clearTimeout(longPressTimer.current);
|
||||
};
|
||||
|
||||
// Deleted messages are filtered out by ChatWindow, but guard here too
|
||||
if (isDeleted) return null;
|
||||
|
||||
// System messages render as a simple centred notice
|
||||
if (isSystem) {
|
||||
return (
|
||||
<>
|
||||
{showDateSep && (
|
||||
<div className="date-separator"><span>{formatDate(msg.created_at)}</span></div>
|
||||
)}
|
||||
<div className="system-message">{msg.content}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
allow_dm: msg.user_allow_dm,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDateSep && (
|
||||
<div className="date-separator">
|
||||
<span>{formatDate(msg.created_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`}
|
||||
>
|
||||
{!isOwn && !prevSameUser && (
|
||||
<div
|
||||
ref={avatarRef}
|
||||
style={{ position: 'relative', cursor: 'pointer', transition: 'box-shadow 0.15s', flexShrink: 0, borderRadius: '50%', display: 'inline-flex' }}
|
||||
onClick={() => setShowProfile(p => !p)}
|
||||
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 0 0 2px var(--primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
|
||||
>
|
||||
<Avatar user={msgUser} size="sm" className="msg-avatar" />
|
||||
{!!(onlineUserIds instanceof Set ? onlineUserIds.has(Number(msg.user_id)) : false) && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: 0, right: 0,
|
||||
width: 9, height: 9, borderRadius: '50%',
|
||||
background: '#34a853', border: '2px solid var(--surface)',
|
||||
pointerEvents: 'none'
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isOwn && prevSameUser && <div className="avatar-spacer" />}
|
||||
|
||||
<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"
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => { if (!showEmojiPicker && !showOptionsMenu) setShowActions(false); }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchEnd}
|
||||
onContextMenu={isDirect ? (e => { e.preventDefault(); setShowOptionsMenu(true); }) : undefined}
|
||||
>
|
||||
{/* 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>
|
||||
{msg.content && (
|
||||
<button className="btn-icon action-btn" onClick={handleCopy} title="Copy text">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></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 && isEmojiOnly(msg.content) ? ' emoji-only' : ''}`}>
|
||||
{msg.image_url && (
|
||||
<img
|
||||
src={msg.image_url}
|
||||
alt="attachment"
|
||||
className="msg-image"
|
||||
onClick={() => setLightboxSrc(msg.image_url)}
|
||||
/>
|
||||
)}
|
||||
{msg.content && (
|
||||
isEmojiOnly(msg.content) && !msg.image_url
|
||||
? <p className="msg-text emoji-msg">{msg.content}</p>
|
||||
: <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)}
|
||||
onDirectMessage={onDirectMessage}
|
||||
/>
|
||||
)}
|
||||
{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 parseTS(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = parseTS(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' });
|
||||
}
|
||||
249
frontend/src/components/MessageInput.css
Normal file
@@ -0,0 +1,249 @@
|
||||
.message-input-area {
|
||||
background: white;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0; /* never compress — always visible above keyboard */
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
/* Centre input content with max-width on wide screens */
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* All direct children of the input area capped at 1024px and centred */
|
||||
.message-input-area > * {
|
||||
max-width: 1024px;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.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: calc(1.4em * 5 + 20px); /* 5 lines × line-height + padding */
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
font-size: calc(0.875rem * var(--font-scale));
|
||||
line-height: 1.4;
|
||||
font-family: var(--font);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-variant);
|
||||
transition: border-color var(--transition);
|
||||
overflow-y: hidden;
|
||||
resize: none;
|
||||
}
|
||||
.msg-input:focus { outline: none; border-color: var(--primary); background: var(--surface-variant); }
|
||||
.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; }
|
||||
|
||||
/* + attach button */
|
||||
.attach-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attach-btn {
|
||||
color: var(--primary);
|
||||
}
|
||||
.attach-btn:hover {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* Attach menu popup */
|
||||
.attach-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.attach-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 16px;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
transition: var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.attach-item:hover {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
.attach-item svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.attach-item:hover svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Emoji picker popover — positioned above the input area */
|
||||
.emoji-input-picker {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
/* PC only: enforce minimum width on the input row so send button never disappears */
|
||||
@media (pointer: fine) and (hover: hover) {
|
||||
.input-row {
|
||||
min-width: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
388
frontend/src/components/MessageInput.jsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import './MessageInput.css';
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
||||
|
||||
// Detect if a string is purely emoji characters (no other text)
|
||||
function isEmojiOnly(str) {
|
||||
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F|\u200D|[\u{1F1E0}-\u{1F1FF}])+$/u;
|
||||
return emojiRegex.test(str.trim());
|
||||
}
|
||||
|
||||
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onlineUserIds = new Set() }) {
|
||||
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 [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const typingTimer = useRef(null);
|
||||
const wasTyping = useRef(false);
|
||||
const mentionStart = useRef(-1);
|
||||
const fileInput = useRef(null);
|
||||
const cameraInput = useRef(null);
|
||||
const attachMenuRef = useRef(null);
|
||||
const emojiPickerRef = useRef(null);
|
||||
|
||||
// Close attach menu / emoji picker on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (attachMenuRef.current && !attachMenuRef.current.contains(e.target)) {
|
||||
setShowAttachMenu(false);
|
||||
}
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target)) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
// 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 — 5 second timeout, then abandon and enable Send
|
||||
const previewTimeoutRef = useRef(null);
|
||||
|
||||
const fetchPreview = useCallback(async (url) => {
|
||||
setLoadingPreview(true);
|
||||
setLinkPreview(null);
|
||||
|
||||
if (previewTimeoutRef.current) clearTimeout(previewTimeoutRef.current);
|
||||
const abandonTimer = setTimeout(() => {
|
||||
setLoadingPreview(false);
|
||||
}, 5000);
|
||||
previewTimeoutRef.current = abandonTimer;
|
||||
|
||||
try {
|
||||
const { preview } = await api.getLinkPreview(url);
|
||||
clearTimeout(abandonTimer);
|
||||
if (preview) setLinkPreview(preview);
|
||||
} catch {
|
||||
clearTimeout(abandonTimer);
|
||||
}
|
||||
setLoadingPreview(false);
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const val = e.target.value;
|
||||
setText(val);
|
||||
handleTypingChange(val);
|
||||
|
||||
const el = e.target;
|
||||
el.style.height = 'auto';
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
|
||||
const maxHeight = lineHeight * 5 + 20;
|
||||
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||
|
||||
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, group?.id).then(({ users }) => {
|
||||
setMentionResults(users);
|
||||
setMentionIndex(0);
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
setShowMention(false);
|
||||
|
||||
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 name = user.display_name || user.name;
|
||||
setText(before + `@[${name}] ` + 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);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
inputRef.current.style.overflowY = 'hidden';
|
||||
}
|
||||
|
||||
const emojiOnly = !!trimmed && isEmojiOnly(trimmed);
|
||||
await onSend({ content: trimmed || null, imageFile, linkPreview: lp, emojiOnly });
|
||||
};
|
||||
|
||||
// Insert emoji at cursor position in the textarea
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
setShowEmojiPicker(false);
|
||||
const el = inputRef.current;
|
||||
const native = emoji.native;
|
||||
|
||||
if (el) {
|
||||
const start = el.selectionStart ?? 0;
|
||||
const end = el.selectionEnd ?? 0;
|
||||
const newText = text.slice(0, start) + native + text.slice(end);
|
||||
setText(newText);
|
||||
// Restore focus and move cursor after the inserted emoji
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
const pos = start + native.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
// Resize textarea
|
||||
el.style.height = 'auto';
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight);
|
||||
const maxHeight = lineHeight * 5 + 20;
|
||||
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||
});
|
||||
} else {
|
||||
// No ref yet — just append
|
||||
setText(prev => prev + native);
|
||||
}
|
||||
};
|
||||
|
||||
const compressImage = (file) => new Promise((resolve) => {
|
||||
const MAX_PX = 1920;
|
||||
const QUALITY = 0.82;
|
||||
const isPng = file.type === 'image/png';
|
||||
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
|
||||
} 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;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!isPng) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
if (isPng) {
|
||||
canvas.toBlob(blob => resolve(new File([blob], file.name, { type: 'image/png' })), 'image/png');
|
||||
} else {
|
||||
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);
|
||||
setShowAttachMenu(false);
|
||||
};
|
||||
|
||||
// Detect mobile (touch device)
|
||||
const isMobile = () => window.matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
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-wrap">
|
||||
<div className="mention-avatar">{(u.display_name || u.name)?.[0]?.toUpperCase()}</div>
|
||||
{onlineUserIds.has(u.id) && <span className="mention-online-dot" />}
|
||||
</div>
|
||||
<span>{u.display_name || u.name}</span>
|
||||
<span className="mention-role">{u.role}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="input-row">
|
||||
|
||||
{/* + button — attach menu trigger */}
|
||||
<div className="attach-wrap" ref={attachMenuRef}>
|
||||
<button
|
||||
className="btn-icon input-action attach-btn"
|
||||
onClick={() => { setShowAttachMenu(v => !v); setShowEmojiPicker(false); }}
|
||||
title="Add photo or emoji"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="22" height="22">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showAttachMenu && (
|
||||
<div className="attach-menu">
|
||||
{/* Photo from library */}
|
||||
<button className="attach-item" onClick={() => fileInput.current?.click()}>
|
||||
<svg width="18" height="18" 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>
|
||||
<span>Photo</span>
|
||||
</button>
|
||||
{/* Camera — mobile only */}
|
||||
{isMobile() && (
|
||||
<button className="attach-item" onClick={() => cameraInput.current?.click()}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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>
|
||||
<span>Camera</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Emoji */}
|
||||
<button className="attach-item" onClick={() => { setShowAttachMenu(false); setShowEmojiPicker(true); }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 13s1.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>
|
||||
<span>Emoji</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file inputs */}
|
||||
<input ref={fileInput} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleImageSelect} />
|
||||
<input ref={cameraInput} type="file" accept="image/*" capture="environment" style={{ display: 'none' }} onChange={handleImageSelect} />
|
||||
|
||||
{/* Emoji picker popover */}
|
||||
{showEmojiPicker && (
|
||||
<div className="emoji-input-picker" ref={emojiPickerRef}>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
theme="light"
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
maxFrequentRows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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) && !loadingPreview ? 'active' : ''}`}
|
||||
onClick={handleSend}
|
||||
disabled={(!text.trim() && !imageFile) || loadingPreview}
|
||||
title={loadingPreview ? 'Loading preview…' : 'Send'}
|
||||
>
|
||||
{loadingPreview
|
||||
? <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
: <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>
|
||||
);
|
||||
}
|
||||
166
frontend/src/components/NewChatModal.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
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);
|
||||
|
||||
// True when exactly 1 user selected on private tab = direct message
|
||||
const isDirect = tab === 'private' && selected.length === 1;
|
||||
|
||||
useEffect(() => {
|
||||
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
api.searchUsers(search).then(({ users }) => setUsers(users)).catch(() => {});
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
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]);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
||||
if (tab === 'private' && selected.length > 1 && !name.trim()) return toast('Name required', 'error');
|
||||
if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
let payload;
|
||||
if (isDirect) {
|
||||
// Direct message: no name, isDirect flag
|
||||
payload = {
|
||||
type: 'private',
|
||||
memberIds: selected.map(u => u.id),
|
||||
isDirect: true,
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
name: name.trim(),
|
||||
type: tab,
|
||||
memberIds: selected.map(u => u.id),
|
||||
isReadonly: tab === 'public' && isReadonly,
|
||||
};
|
||||
}
|
||||
|
||||
const { group, duplicate } = await api.createGroup(payload);
|
||||
if (duplicate) {
|
||||
toast('A group with these members already exists — opening it now.', 'info');
|
||||
} else {
|
||||
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
|
||||
}
|
||||
onCreated(group);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Placeholder for the name field
|
||||
const namePlaceholder = isDirect
|
||||
? selected[0]?.name || ''
|
||||
: tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team';
|
||||
|
||||
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')}>Direct Message</button>
|
||||
<button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Message</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Name — only shown when needed: public always, private only when 2+ members selected */}
|
||||
{(tab === 'public' || (tab === 'private' && selected.length > 1)) && (
|
||||
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label>
|
||||
<input
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={namePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Readonly toggle for public */}
|
||||
{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 message (only admins can post)
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Member selector for private tab */}
|
||||
{tab === 'private' && (
|
||||
<>
|
||||
<div className="flex-col gap-2" style={{ marginBottom: 12 }}>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{isDirect ? 'Direct Message with' : '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.name}
|
||||
<span className="chip-remove" onClick={() => toggle(u)}>×</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDirect && (
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 12, fontStyle: 'italic' }}>
|
||||
A private two-person conversation. Select a second person to create a group instead.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
||||
{users.filter(u => u.id !== user.id && u.allow_dm !== 0).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.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...' : isDirect ? 'Start Conversation' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
frontend/src/components/ProfileModal.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
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 [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
|
||||
const [displayNameWarning, setDisplayNameWarning] = useState('');
|
||||
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 [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (displayNameWarning) return toast('Display name is already in use', 'error');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm });
|
||||
updateUser(updated);
|
||||
setSavedDisplayName(displayName);
|
||||
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>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={displayName}
|
||||
onChange={async e => {
|
||||
const val = e.target.value;
|
||||
setDisplayName(val);
|
||||
setDisplayNameWarning('');
|
||||
if (val && val !== user?.display_name) {
|
||||
try {
|
||||
const { taken } = await api.checkDisplayName(val);
|
||||
if (taken) setDisplayNameWarning('Display name is already in use');
|
||||
} catch {}
|
||||
}
|
||||
}}
|
||||
placeholder={user?.name}
|
||||
style={{ borderColor: displayNameWarning ? '#e53935' : undefined }}
|
||||
/>
|
||||
{displayName !== savedDisplayName ? null : savedDisplayName ? (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ background: 'var(--surface-variant)', color: 'var(--text-secondary)', flexShrink: 0 }}
|
||||
onClick={() => setDisplayName('')}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{displayNameWarning && <span className="text-xs" style={{ color: '#e53935' }}>{displayNameWarning}</span>}
|
||||
{savedDisplayName && <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Username: {user?.name}</span>}
|
||||
</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>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm pointer" style={{ color: 'var(--text-secondary)', userSelect: 'none' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowDm}
|
||||
onChange={e => setAllowDm(e.target.checked)}
|
||||
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }}
|
||||
/>
|
||||
Allow others to send me direct 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>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/SettingsModal.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
export default function SettingsModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [vapidPublic, setVapidPublic] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [showRegenWarning, setShowRegenWarning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setVapidPublic(settings.vapid_public || '');
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const doGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setShowRegenWarning(false);
|
||||
try {
|
||||
const { publicKey } = await api.generateVapidKeys();
|
||||
setVapidPublic(publicKey);
|
||||
toast('VAPID keys generated. Push notifications are now active.', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message || 'Failed to generate keys', 'error');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateClick = () => {
|
||||
if (vapidPublic) {
|
||||
setShowRegenWarning(true);
|
||||
} else {
|
||||
doGenerate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 480 }}>
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>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>
|
||||
|
||||
<div>
|
||||
<div className="settings-section-label">Web Push Notifications (VAPID)</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)" }}>Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{vapidPublic ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{
|
||||
background: "var(--surface-variant)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "10px 12px",
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: "var(--text-tertiary)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||||
Public Key
|
||||
</div>
|
||||
<code style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-primary)",
|
||||
wordBreak: "break-all",
|
||||
lineHeight: 1.5,
|
||||
display: "block",
|
||||
}}>
|
||||
{vapidPublic}
|
||||
</code>
|
||||
</div>
|
||||
<span style={{ fontSize: 13, color: "var(--success)", display: "flex", alignItems: "center", gap: 5 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Push notifications active
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 12 }}>
|
||||
No VAPID keys found. Generate keys to enable Web Push notifications — users can then receive alerts when the app is closed or in the background.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showRegenWarning && (
|
||||
<div style={{
|
||||
background: "#fce8e6",
|
||||
border: "1px solid #f5c6c2",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "14px 16px",
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--error)", marginBottom: 8 }}>
|
||||
⚠️ Regenerate VAPID keys?
|
||||
</p>
|
||||
<p style={{ fontSize: 13, color: "#5c2c28", marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Every user will stop receiving push notifications immediately and will need to re-enable them by opening the app. This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
style={{ background: "var(--error)", color: "white" }}
|
||||
onClick={doGenerate}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? "Generating…" : "Yes, regenerate keys"}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showRegenWarning && (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleGenerateClick} disabled={generating}>
|
||||
{generating ? "Generating…" : vapidPublic ? "Regenerate Keys" : "Generate Keys"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 12, lineHeight: 1.5 }}>
|
||||
Requires HTTPS. After generating, users will be prompted to enable notifications on their next visit. On iOS, the app must be installed to the home screen first.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
frontend/src/components/Sidebar.css
Normal file
@@ -0,0 +1,283 @@
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: white;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar { width: 100vw; min-width: 0; }
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.offline-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #e53935;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* New Chat bar (desktop) */
|
||||
.sidebar-newchat-bar {
|
||||
padding: 10px 12px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.newchat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 9px 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border: 1.5px solid var(--primary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.newchat-btn:hover { background: var(--primary); color: white; }
|
||||
.newchat-btn:hover svg { stroke: white; }
|
||||
|
||||
/* Mobile FAB */
|
||||
.newchat-fab {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
right: 16px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), transform 80ms ease;
|
||||
border: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.newchat-fab:hover { transform: scale(1.05); }
|
||||
.newchat-fab:active { transform: scale(0.97); }
|
||||
|
||||
.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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 8px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.group-item.has-unread { background: var(--surface-variant); }
|
||||
.unread-name { font-weight: 700; color: var(--text-primary) !important; }
|
||||
.badge-unread {
|
||||
background: #e53935;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
padding: 0 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* DM real name in brackets — smaller, muted */
|
||||
.dm-real-name {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Online presence dot on DM avatars */
|
||||
.group-icon-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
right: 1px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
background: #34a853;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--surface);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* DM right-click context menu */
|
||||
.dm-context-menu {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.dm-context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.dm-context-menu button:hover {
|
||||
background: var(--surface-variant);
|
||||
}
|
||||
263
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { api, parseTS } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import './Sidebar.css';
|
||||
|
||||
function useTheme() {
|
||||
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
|
||||
}, [dark]);
|
||||
return [dark, setDark];
|
||||
}
|
||||
|
||||
function useAppSettings() {
|
||||
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
|
||||
const fetchSettings = () => {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
window.addEventListener('jama:settings-changed', fetchSettings);
|
||||
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const name = settings.app_name || 'jama';
|
||||
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
|
||||
document.title = prefix + name;
|
||||
const faviconUrl = settings.logo_url || '/icons/jama.png';
|
||||
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;
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = parseTS(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' });
|
||||
}
|
||||
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set() }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { connected } = useSocket();
|
||||
const toast = useToast();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const settings = useAppSettings();
|
||||
const [dark, setDark] = useTheme();
|
||||
const menuRef = useRef(null);
|
||||
const footerBtnRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMenu) return;
|
||||
const handler = (e) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target) &&
|
||||
footerBtnRef.current && !footerBtnRef.current.contains(e.target)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
document.addEventListener('touchstart', handler);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handler);
|
||||
document.removeEventListener('touchstart', handler);
|
||||
};
|
||||
}, [showMenu]);
|
||||
|
||||
const allGroups = [
|
||||
...(groups.publicGroups || []),
|
||||
...(groups.privateGroups || [])
|
||||
];
|
||||
|
||||
const publicFiltered = allGroups.filter(g => g.type === 'public');
|
||||
|
||||
const privateFiltered = [...allGroups.filter(g => g.type === 'private')].sort((a, b) => {
|
||||
if (!a.last_message_at && !b.last_message_at) return 0;
|
||||
if (!a.last_message_at) return 1;
|
||||
if (!b.last_message_at) return -1;
|
||||
return new Date(b.last_message_at) - new Date(a.last_message_at);
|
||||
});
|
||||
|
||||
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||
const handleLogout = async () => { await logout(); };
|
||||
|
||||
const GroupItem = ({ group }) => {
|
||||
const notifs = getNotifCount(group.id);
|
||||
const unreadCount = unreadGroups.get(group.id) || 0;
|
||||
const hasUnread = unreadCount > 0;
|
||||
const isActive = group.id === activeGroupId;
|
||||
const isOnline = !!group.is_direct && !!group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
|
||||
onClick={() => onSelectGroup(group.id)}
|
||||
>
|
||||
<div className="group-icon-wrap">
|
||||
{group.is_direct && group.peer_avatar ? (
|
||||
<img src={group.peer_avatar} alt={group.name} className="group-icon" style={{ objectFit: 'cover', padding: 0 }} />
|
||||
) : (
|
||||
<div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#a142f4') }}>
|
||||
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{isOnline && <span className="online-dot" />}
|
||||
</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.is_direct && group.peer_display_name
|
||||
? <>{group.peer_display_name}<span className="dm-real-name"> ({group.peer_real_name})</span></>
|
||||
: group.is_direct && group.peer_real_name ? group.peer_real_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">
|
||||
{(() => {
|
||||
const preview = (group.last_message || '').replace(/@\[([^\]]+)\]/g, '@$1');
|
||||
if (!preview) return group.is_readonly ? '📢 Read-only' : 'No messages yet';
|
||||
const isOwn = group.last_message_user_id && user && group.last_message_user_id === user.id;
|
||||
return isOwn ? <><strong style={{ fontWeight: 600 }}>You:</strong> {preview}</> : preview;
|
||||
})()}
|
||||
</span>
|
||||
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
|
||||
{hasUnread && notifs === 0 && <span className="badge badge-unread shrink-0">{unreadCount}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-newchat-bar">
|
||||
{!isMobile && (
|
||||
<button className="newchat-btn" onClick={onNewChat}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="18" height="18">
|
||||
<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>
|
||||
New Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="groups-list">
|
||||
{publicFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">PUBLIC MESSAGES</div>
|
||||
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
{privateFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">MESSAGES</div>
|
||||
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
{allGroups.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
|
||||
No chats yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<button className="newchat-fab" onClick={onNewChat} title="New Chat">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24">
|
||||
<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 className="sidebar-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button ref={footerBtnRef} className="user-footer-btn" style={{ flex: 1 }} 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>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDark(d => !d)}
|
||||
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
style={{ flexShrink: 0, padding: 8 }}
|
||||
>
|
||||
{dark ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMenu && (
|
||||
<div ref={menuRef} className="footer-menu">
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); 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={() => { setShowMenu(false); 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={() => { setShowMenu(false); onBranding && onBranding(); }}>
|
||||
<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="M12 2a10 10 0 1 0 10 10"/><path d="M12 6V2M12 22v-4M6 12H2M22 12h-4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
|
||||
Branding
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); 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" onClick={() => { setShowMenu(false); onHelp && onHelp(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
Help
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onAbout && onAbout(); }}>
|
||||
<svg width="16" height="16" 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>
|
||||
About
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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)}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
415
frontend/src/components/UserManagerModal.jsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
function isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
function parseCSV(text) {
|
||||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
||||
const rows = [], invalid = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (i === 0 && /^name\s*,/i.test(line)) continue;
|
||||
const parts = line.split(',').map(p => p.trim());
|
||||
if (parts.length < 2 || parts.length > 4) { invalid.push({ line, reason: 'Must have 2–4 comma-separated fields' }); continue; }
|
||||
const [name, email, password, role] = parts;
|
||||
if (!name || !/\S+\s+\S+/.test(name)) { invalid.push({ line, reason: 'Name must be two words (First Last)' }); continue; }
|
||||
if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email}"` }); continue; }
|
||||
rows.push({ name: name.trim(), email: email.trim().toLowerCase(), password: (password || '').trim(), role: (role || 'member').trim().toLowerCase() });
|
||||
}
|
||||
return { rows, invalid };
|
||||
}
|
||||
|
||||
function UserRow({ u, onUpdated }) {
|
||||
const toast = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resetPw, setResetPw] = useState('');
|
||||
const [showReset, setShowReset] = useState(false);
|
||||
const [editName, setEditName] = useState(false);
|
||||
const [nameVal, setNameVal] = useState(u.name);
|
||||
const [roleWarning, setRoleWarning] = useState(false);
|
||||
|
||||
const handleRole = async (role) => {
|
||||
if (!role) { setRoleWarning(true); return; }
|
||||
setRoleWarning(false);
|
||||
try { await api.updateRole(u.id, role); toast('Role updated', 'success'); onUpdated(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleResetPw = async () => {
|
||||
if (!resetPw || resetPw.length < 6) return toast('Min 6 characters', 'error');
|
||||
try { await api.resetPassword(u.id, resetPw); toast('Password reset', 'success'); setShowReset(false); setResetPw(''); onUpdated(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!nameVal.trim()) return toast('Name cannot be empty', 'error');
|
||||
try {
|
||||
const { name } = await api.updateName(u.id, nameVal.trim());
|
||||
toast(name !== nameVal.trim() ? `Saved as "${name}"` : 'Name updated', 'success');
|
||||
setEditName(false); onUpdated();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleSuspend = async () => {
|
||||
if (!confirm(`Suspend ${u.name}?`)) return;
|
||||
try { await api.suspendUser(u.id); toast('User suspended', 'success'); onUpdated(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleActivate = async () => {
|
||||
try { await api.activateUser(u.id); toast('User activated', 'success'); onUpdated(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (u.role === 'admin') return toast('Demote to member before deleting an admin', 'error');
|
||||
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'); onUpdated(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{/* Row header — always visible */}
|
||||
<button
|
||||
onClick={() => { setOpen(o => !o); setShowReset(false); setEditName(false); }}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '10px 4px', background: 'none', border: 'none', cursor: 'pointer',
|
||||
textAlign: 'left', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<Avatar user={u} size="sm" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{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>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.email}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 1 }}>
|
||||
Last online: {(() => {
|
||||
if (!u.last_online) return 'Never';
|
||||
const d = new Date(u.last_online + 'Z');
|
||||
const today = new Date(); today.setHours(0,0,0,0);
|
||||
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
|
||||
d.setHours(0,0,0,0);
|
||||
if (d >= today) return 'Today';
|
||||
if (d >= yesterday) return 'Yesterday';
|
||||
return d.toISOString().slice(0,10);
|
||||
})()}
|
||||
</div>
|
||||
{!!u.must_change_password && <div className="text-xs" style={{ color: 'var(--warning)' }}>⚠ Must change password</div>}
|
||||
</div>
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
style={{ flexShrink: 0, transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Accordion panel */}
|
||||
{open && !u.is_default_admin && (
|
||||
<div style={{ padding: '4px 4px 14px 44px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
|
||||
{/* Edit name */}
|
||||
{editName ? (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, fontSize: 13, padding: '5px 8px' }}
|
||||
value={nameVal}
|
||||
onChange={e => setNameVal(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditName(false); setNameVal(u.name); } }}
|
||||
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSaveName}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setEditName(false); setNameVal(u.name); }}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||
onClick={() => { setEditName(true); setShowReset(false); }}
|
||||
>
|
||||
<svg width="12" height="12" 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>
|
||||
Edit Name
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Role selector */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
|
||||
<select
|
||||
value={roleWarning ? '' : u.role}
|
||||
onChange={e => handleRole(e.target.value)}
|
||||
className="input"
|
||||
style={{ width: 140, padding: '5px 8px', fontSize: 13, borderColor: roleWarning ? '#e53935' : undefined }}
|
||||
>
|
||||
<option value="" disabled>User Role</option>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
{roleWarning && <span style={{ fontSize: 12, color: '#e53935' }}>Role Required</span>}
|
||||
</div>
|
||||
|
||||
{/* Reset password */}
|
||||
{showReset ? (
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: 1, fontSize: 13, padding: '5px 8px' }}
|
||||
type="text"
|
||||
placeholder="New password (min 6)"
|
||||
value={resetPw}
|
||||
onChange={e => setResetPw(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleResetPw(); if (e.key === 'Escape') { setShowReset(false); setResetPw(''); } }}
|
||||
|
||||
/>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleResetPw}>Set</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setShowReset(false); setResetPw(''); }}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||
onClick={() => { setShowReset(true); setEditName(false); }}
|
||||
>
|
||||
<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"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
Reset Password
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Suspend / Activate / Delete */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{u.status === 'active' ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleSuspend}>Suspend</button>
|
||||
) : u.status === 'suspended' ? (
|
||||
<button className="btn btn-secondary btn-sm" style={{ color: 'var(--success)' }} onClick={handleActivate}>Activate</button>
|
||||
) : null}
|
||||
<button className="btn btn-danger btn-sm" onClick={handleDelete}>Delete User</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserManagerModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tab, setTab] = useState('users');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' });
|
||||
|
||||
const [csvFile, setCsvFile] = useState(null);
|
||||
const [csvRows, setCsvRows] = useState([]);
|
||||
const [csvInvalid, setCsvInvalid] = useState([]);
|
||||
const [bulkResult, setBulkResult] = useState(null);
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
|
||||
const fileRef = useRef(null);
|
||||
const [userPass, setUserPass] = useState('user@1234');
|
||||
|
||||
const load = () => {
|
||||
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => {
|
||||
load();
|
||||
api.getSettings().then(({ settings }) => {
|
||||
if (settings.user_pass) setUserPass(settings.user_pass);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const filtered = users.filter(u =>
|
||||
!search || u.name?.toLowerCase().includes(search.toLowerCase()) || u.email?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.name.trim() || !form.email.trim()) return toast('Name and email are required', 'error');
|
||||
if (!isValidEmail(form.email)) return toast('Invalid email address', 'error');
|
||||
if (!/\S+\s+\S+/.test(form.name.trim())) return toast('Name must be two words (First Last)', '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 handleFileSelect = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setCsvFile(file);
|
||||
setBulkResult(null);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const { rows, invalid } = parseCSV(ev.target.result);
|
||||
setCsvRows(rows);
|
||||
setCsvInvalid(invalid);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleBulkImport = async () => {
|
||||
if (!csvRows.length) return;
|
||||
setBulkLoading(true);
|
||||
try {
|
||||
const result = await api.bulkUsers(csvRows);
|
||||
setBulkResult(result);
|
||||
setCsvRows([]); setCsvFile(null); setCsvInvalid([]);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
load();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setBulkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 600, width: '100%' }}>
|
||||
<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>
|
||||
|
||||
<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 — accordion */}
|
||||
{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: '60vh', overflowY: 'auto' }}>
|
||||
{filtered.map(u => (
|
||||
<UserRow key={u.id} u={u} onUpdated={load} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create user */}
|
||||
{tab === 'create' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Full Name <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(First Last)</span></label>
|
||||
<input className="input" placeholder="Jane Smith" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Email</label>
|
||||
<input className="input" type="email" placeholder="jane@example.com" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password <span style={{ fontWeight: 400, color: 'var(--text-tertiary)' }}>(blank = {userPass || 'USER_PASS'})</span></label>
|
||||
<input className="input" type="text" 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 must change password on first login. Duplicate names get a number suffix automatically.</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: 6 }}>CSV Format</p>
|
||||
<code style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'block', background: 'var(--surface)', padding: 8, borderRadius: 4, border: '1px solid var(--border)', whiteSpace: 'pre' }}>name,email,password,role{'\n'}Jane Smith,jane@company.local,,member{'\n'}Bob Jones,bob@company.com,TempPass1,admin</code>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||
Name and email are required. If left blank, Temp Password defaults to <strong>{userPass}</strong>, Role defaults to member. Lines with duplicate emails are skipped. Duplicate names get a number suffix.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<label className="btn btn-secondary" style={{ cursor: 'pointer', margin: 0, flexShrink: 0 }}>
|
||||
Select CSV File
|
||||
<input ref={fileRef} type="file" accept=".csv,.txt" style={{ display: 'none' }} onChange={handleFileSelect} />
|
||||
</label>
|
||||
{csvFile && (
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
{csvFile.name}
|
||||
{csvRows.length > 0 && <span style={{ color: 'var(--text-tertiary)', marginLeft: 6 }}>({csvRows.length} valid)</span>}
|
||||
</span>
|
||||
)}
|
||||
{csvRows.length > 0 && (
|
||||
<button className="btn btn-primary" style={{ flexShrink: 0 }} onClick={handleBulkImport} disabled={bulkLoading}>
|
||||
{bulkLoading ? 'Creating…' : `Create ${csvRows.length} User${csvRows.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{csvInvalid.length > 0 && (
|
||||
<div style={{ background: 'rgba(229,57,53,0.07)', border: '1px solid #e53935', borderRadius: 'var(--radius)', padding: 10 }}>
|
||||
<p className="text-sm font-medium" style={{ color: '#e53935', marginBottom: 6 }}>{csvInvalid.length} line{csvInvalid.length !== 1 ? 's' : ''} skipped — invalid format</p>
|
||||
<div style={{ maxHeight: 100, overflowY: 'auto' }}>
|
||||
{csvInvalid.map((e, i) => (
|
||||
<div key={i} style={{ fontSize: 12, padding: '2px 0', color: 'var(--text-secondary)' }}>
|
||||
<code style={{ fontSize: 11 }}>{e.line}</code>
|
||||
<span style={{ color: '#e53935', marginLeft: 8 }}>— {e.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bulkResult && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 12 }}>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--success, #2e7d32)', marginBottom: bulkResult.skipped.length ? 8 : 0 }}>
|
||||
✓ {bulkResult.created.length} user{bulkResult.created.length !== 1 ? 's' : ''} created successfully
|
||||
</p>
|
||||
{bulkResult.skipped.length > 0 && (
|
||||
<>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 6 }}>{bulkResult.skipped.length} account{bulkResult.skipped.length !== 1 ? 's' : ''} skipped:</p>
|
||||
<div style={{ maxHeight: 112, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
||||
{bulkResult.skipped.map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 10px', borderBottom: i < bulkResult.skipped.length - 1 ? '1px solid var(--border)' : 'none', fontSize: 13, gap: 12 }}>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{s.email}</span>
|
||||
<span style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>{s.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-sm" style={{ marginTop: 10 }} onClick={() => setBulkResult(null)}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
frontend/src/components/UserProfilePopup.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
|
||||
export default function UserProfilePopup({ user: profileUser, anchorEl, onClose, onDirectMessage }) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const popupRef = useRef(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const isSelf = currentUser?.id === profileUser?.id;
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!popupRef.current || !anchorEl) return;
|
||||
const anchor = anchorEl.getBoundingClientRect();
|
||||
const popup = popupRef.current;
|
||||
const viewportH = window.innerHeight;
|
||||
const viewportW = window.innerWidth;
|
||||
|
||||
let top = anchor.bottom + 8;
|
||||
let left = anchor.left;
|
||||
|
||||
if (top + 260 > viewportH) top = anchor.top - 268;
|
||||
if (left + 220 > viewportW) left = viewportW - 228;
|
||||
|
||||
popup.style.top = `${top}px`;
|
||||
popup.style.left = `${left}px`;
|
||||
}, [anchorEl]);
|
||||
|
||||
const handleDM = async () => {
|
||||
if (!onDirectMessage) return;
|
||||
setStarting(true);
|
||||
try {
|
||||
const { group } = await api.createGroup({
|
||||
type: 'private',
|
||||
memberIds: [profileUser.id],
|
||||
isDirect: true,
|
||||
});
|
||||
onClose();
|
||||
onDirectMessage(group);
|
||||
} catch (e) {
|
||||
console.error('DM error', e);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!profileUser) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popupRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 1000,
|
||||
background: 'var(--surface)',
|
||||
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={profileUser} size="xl" />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)', marginBottom: 2 }}>
|
||||
{profileUser.name}
|
||||
</div>
|
||||
{profileUser.role === 'admin' && !profileUser.hide_admin_tag && (
|
||||
<span className="role-badge role-admin" style={{ fontSize: 11 }}>Admin</span>
|
||||
)}
|
||||
</div>
|
||||
{profileUser.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%',
|
||||
}}>
|
||||
{profileUser.about_me}
|
||||
</p>
|
||||
)}
|
||||
{!isSelf && onDirectMessage && (
|
||||
profileUser.allow_dm === 0 ? (
|
||||
<p style={{
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-tertiary)',
|
||||
fontStyle: 'italic',
|
||||
}}>
|
||||
DMs disabled by user
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDM}
|
||||
disabled={starting}
|
||||
style={{
|
||||
marginTop: 6,
|
||||
width: '100%',
|
||||
padding: '8px 0',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--primary)',
|
||||
background: 'transparent',
|
||||
color: 'var(--primary)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: starting ? 'default' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
transition: 'background var(--transition), color var(--transition)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--primary)'; e.currentTarget.style.color = 'white'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--primary)'; }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<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>
|
||||
{starting ? 'Opening...' : 'Direct Message'}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
};
|
||||
|
||||
// Listen for session displacement (another device logged in)
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setUser(null);
|
||||
setMustChangePassword(false);
|
||||
};
|
||||
window.addEventListener('jama:session-displaced', handler);
|
||||
return () => window.removeEventListener('jama:session-displaced', handler);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
69
frontend/src/contexts/SocketContext.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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'],
|
||||
// Aggressive reconnection so mobile resume is fast
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 500,
|
||||
reconnectionDelayMax: 3000,
|
||||
timeout: 8000,
|
||||
});
|
||||
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; }));
|
||||
|
||||
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (socketRef.current && !socketRef.current.connected) {
|
||||
socketRef.current.connect();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
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
@@ -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);
|
||||
469
frontend/src/index.css
Normal file
@@ -0,0 +1,469 @@
|
||||
@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;
|
||||
--font-scale: 1;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; min-width: 320px; font-family: var(--font); color: var(--text-primary); background: var(--background); }
|
||||
|
||||
/* Disable pull-to-refresh in PWA standalone mode — prevents viewport shift bug */
|
||||
html {
|
||||
overscroll-behavior-y: none;
|
||||
font-size: 100%; /* inherits system font size — allows Android accessibility font scaling */
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Dark mode ─────────────────────────────────────────── */
|
||||
[data-theme="dark"] {
|
||||
--primary: #6ab0f5;
|
||||
--primary-dark: #4d9de0;
|
||||
--primary-light: #1a2d4a;
|
||||
--surface: #1e1e2e;
|
||||
--surface-variant: #252535;
|
||||
--background: #13131f;
|
||||
--border: #2e2e45;
|
||||
--text-primary: #e2e2f0;
|
||||
--text-secondary: #9898b8;
|
||||
--text-tertiary: #606080;
|
||||
--bubble-out: #4d8fd4;
|
||||
--bubble-in: #252535;
|
||||
}
|
||||
[data-theme="dark"] body,
|
||||
[data-theme="dark"] html,
|
||||
[data-theme="dark"] #root { background: var(--background); }
|
||||
[data-theme="dark"] .modal { background: var(--surface); }
|
||||
[data-theme="dark"] .footer-menu { background: var(--surface); }
|
||||
[data-theme="dark"] .sidebar { background: var(--surface); }
|
||||
[data-theme="dark"] .chat-window { background: var(--background); }
|
||||
[data-theme="dark"] .chat-header { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .messages-container { background: var(--background); }
|
||||
[data-theme="dark"] .input { background: var(--surface-variant); border-color: var(--border); color: var(--text-primary); }
|
||||
[data-theme="dark"] .card { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .message-input-area { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .message-input-wrap { background: var(--surface-variant); border-color: var(--border); }
|
||||
[data-theme="dark"] .msg-input:focus { background: var(--surface-variant); color: var(--text-primary); }
|
||||
/* Light mode: focused input goes white so it pops from the grey background */
|
||||
[data-theme="light"] .msg-input:focus, :root:not([data-theme="dark"]) .msg-input:focus { background: white; }
|
||||
[data-theme="dark"] .btn-secondary { border-color: var(--border); color: var(--primary); }
|
||||
[data-theme="dark"] .btn-secondary:hover { background: var(--primary-light); }
|
||||
[data-theme="dark"] .search-input { background: var(--surface-variant); color: var(--text-primary); }
|
||||
[data-theme="dark"] .group-item:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .group-item.active { background: var(--primary-light); }
|
||||
[data-theme="dark"] .user-footer-btn:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .footer-menu-item:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .footer-menu-item.danger:hover { background: #3a1a1a; }
|
||||
[data-theme="dark"] .btn-icon { color: var(--text-primary); }
|
||||
[data-theme="dark"] .btn-icon:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .sidebar-header { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .newchat-btn { background: var(--surface-variant); border-color: var(--primary); color: var(--primary); }
|
||||
[data-theme="dark"] .newchat-btn:hover { background: var(--primary); color: white; }
|
||||
[data-theme="dark"] .newchat-fab { background: var(--primary); }
|
||||
[data-theme="dark"] .msg-actions { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .reaction-btn:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .emoji-picker-wrap { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .reply-preview { background: var(--surface-variant); border-color: var(--primary); }
|
||||
[data-theme="dark"] .load-more-btn { background: var(--surface-variant); color: var(--text-secondary); }
|
||||
[data-theme="dark"] .readonly-bar { background: var(--surface); border-color: var(--border); color: var(--text-secondary); }
|
||||
[data-theme="dark"] .warning-banner { background: #2a1f00; border-color: #6a4a00; color: #ffb74d; }
|
||||
|
||||
/* ── About Modal ─────────────────────────────────────── */
|
||||
.about-modal {
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding: 32px 28px 24px;
|
||||
}
|
||||
.about-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
.about-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.about-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.about-appname {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.about-tagline {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
.about-table {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: visible;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
.about-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 12px;
|
||||
}
|
||||
.about-row:last-child { border-bottom: none; }
|
||||
.about-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 90px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.about-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
white-space: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.about-mono {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.about-link {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.about-link:hover { opacity: 0.8; }
|
||||
.about-footer {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
[data-theme="dark"] .about-table { border-color: var(--border); }
|
||||
[data-theme="dark"] .about-row { border-color: var(--border); }
|
||||
|
||||
[data-theme="dark"] .attach-menu {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .attach-item {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
[data-theme="dark"] .attach-item:hover {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
[data-theme="dark"] .attach-item svg {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
[data-theme="dark"] .mention-dropdown {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .mention-item {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
[data-theme="dark"] .mention-item:hover,
|
||||
[data-theme="dark"] .mention-item.active {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
[data-theme="dark"] .mention-role {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
[data-theme="dark"] .mention-avatar {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
|
||||
/* Night mode: .in bubble (dark surface) — base mention colour is too dark, use light primary */
|
||||
[data-theme="dark"] .in .mention {
|
||||
color: #6ab0f5;
|
||||
background: rgba(106,176,245,0.15);
|
||||
}
|
||||
|
||||
/* Night mode: .out bubble — white stays white, just ensure background tint works on darker primary */
|
||||
[data-theme="dark"] .out .mention {
|
||||
color: #ffffff;
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .reaction-btn {
|
||||
background: var(--surface-variant);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .reaction-btn:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
/* ── Help Modal ─────────────────────────────────────────────── */
|
||||
.help-modal {
|
||||
width: min(720px, 94vw);
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
background: var(--surface-variant);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.help-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
/* Markdown typography */
|
||||
.help-markdown h1 { font-size: 1.5rem; font-weight: 700; margin: 0 0 16px; color: var(--text-primary); }
|
||||
.help-markdown h2 { font-size: 1.15rem; font-weight: 700; margin: 24px 0 10px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 4px; }
|
||||
.help-markdown h3 { font-size: 1rem; font-weight: 600; margin: 16px 0 6px; color: var(--text-primary); }
|
||||
.help-markdown p { margin: 0 0 12px; line-height: 1.65; color: var(--text-secondary); font-size: 14px; }
|
||||
.help-markdown ul, .help-markdown ol { margin: 0 0 12px 20px; color: var(--text-secondary); font-size: 14px; }
|
||||
.help-markdown li { margin-bottom: 4px; line-height: 1.6; }
|
||||
.help-markdown strong { font-weight: 600; color: var(--text-primary); }
|
||||
.help-markdown em { font-style: italic; }
|
||||
.help-markdown code { font-family: monospace; font-size: 13px; background: var(--background); padding: 1px 5px; border-radius: 4px; color: var(--primary); }
|
||||
.help-markdown pre { background: var(--background); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; overflow-x: auto; margin: 0 0 12px; }
|
||||
.help-markdown pre code { background: none; padding: 0; color: var(--text-primary); }
|
||||
.help-markdown blockquote { border-left: 3px solid var(--primary); margin: 0 0 12px; padding: 6px 14px; background: var(--primary-light); border-radius: 0 var(--radius) var(--radius) 0; }
|
||||
.help-markdown blockquote p { margin: 0; color: var(--text-secondary); }
|
||||
.help-markdown hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
||||
.help-markdown a { color: var(--primary); text-decoration: underline; }
|
||||
|
||||
[data-theme="dark"] .help-markdown code { background: var(--surface); }
|
||||
[data-theme="dark"] .help-markdown pre { background: var(--surface); }
|
||||
[data-theme="dark"] .help-markdown blockquote { background: rgba(99,102,241,0.1); }
|
||||
|
||||
/* Mention picker online dot */
|
||||
.mention-avatar-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mention-online-dot {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: #34a853;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--surface);
|
||||
pointer-events: none;
|
||||
}
|
||||
95
frontend/src/main.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ─── Touch gesture handler ───────────────────────────────────────────────────
|
||||
// Handles two behaviours in one unified listener set to avoid conflicts:
|
||||
//
|
||||
// 1. PINCH → font scale only (not viewport zoom).
|
||||
// viewport has user-scalable=no so the browser never zooms the layout.
|
||||
// We intercept the pinch and adjust --font-scale on <html> instead,
|
||||
// which scales only text (rem-based font sizes). Persisted to localStorage.
|
||||
// On first launch, html { font-size: 100% } inherits the Android system
|
||||
// font size as the 1rem baseline automatically.
|
||||
//
|
||||
// 2. PULL-TO-REFRESH → blocked in PWA standalone mode only.
|
||||
(function () {
|
||||
const LS_KEY = 'jama_font_scale';
|
||||
const MIN_SCALE = 0.8;
|
||||
const MAX_SCALE = 2.0;
|
||||
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true;
|
||||
|
||||
// Restore saved font scale on launch
|
||||
const saved = parseFloat(localStorage.getItem(LS_KEY));
|
||||
let currentScale = (saved >= MIN_SCALE && saved <= MAX_SCALE) ? saved : 1.0;
|
||||
document.documentElement.style.setProperty('--font-scale', currentScale);
|
||||
|
||||
let pinchStartDist = null;
|
||||
let pinchStartScale = currentScale;
|
||||
let singleStartY = 0;
|
||||
|
||||
function getTouchDist(e) {
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length === 2) {
|
||||
pinchStartDist = getTouchDist(e);
|
||||
pinchStartScale = currentScale;
|
||||
} else if (e.touches.length === 1) {
|
||||
singleStartY = e.touches[0].clientY;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchmove', function (e) {
|
||||
if (e.touches.length === 2 && pinchStartDist !== null) {
|
||||
// Two-finger pinch: scale fonts, not viewport
|
||||
e.preventDefault();
|
||||
const ratio = getTouchDist(e) / pinchStartDist;
|
||||
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinchStartScale * ratio));
|
||||
currentScale = Math.round(newScale * 100) / 100;
|
||||
document.documentElement.style.setProperty('--font-scale', currentScale);
|
||||
} else if (e.touches.length === 1 && isStandalone) {
|
||||
// Single finger: block pull-to-refresh at top of page
|
||||
const dy = e.touches[0].clientY - singleStartY;
|
||||
if (dy > 0 && document.documentElement.scrollTop === 0 && document.body.scrollTop === 0) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
document.addEventListener('touchend', function (e) {
|
||||
if (e.touches.length < 2 && pinchStartDist !== null) {
|
||||
pinchStartDist = null;
|
||||
localStorage.setItem(LS_KEY, currentScale);
|
||||
}
|
||||
}, { passive: true });
|
||||
})();
|
||||
|
||||
// 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
@@ -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>
|
||||
);
|
||||
}
|
||||
94
frontend/src/pages/Chat.css
Normal file
@@ -0,0 +1,94 @@
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* Global top bar */
|
||||
.global-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
height: 72px;
|
||||
min-height: 72px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 20;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.global-bar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.global-bar-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.global-bar-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.global-bar-offline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #e53935;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.offline-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Body below global bar */
|
||||
.chat-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0; /* allows body to shrink when mobile keyboard resizes viewport */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.chat-layout {
|
||||
position: relative;
|
||||
}
|
||||
.chat-body {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.global-bar {
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] .global-bar {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.no-chat-selected {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
349
frontend/src/pages/Chat.jsx
Normal file
@@ -0,0 +1,349 @@
|
||||
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 BrandingModal from '../components/BrandingModal.jsx';
|
||||
import NewChatModal from '../components/NewChatModal.jsx';
|
||||
import GlobalBar from '../components/GlobalBar.jsx';
|
||||
import AboutModal from '../components/AboutModal.jsx';
|
||||
import HelpModal from '../components/HelpModal.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 [onlineUserIds, setOnlineUserIds] = useState(new Set());
|
||||
const [activeGroupId, setActiveGroupId] = useState(null);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadGroups, setUnreadGroups] = useState(new Map());
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help'
|
||||
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
|
||||
// Check if help should be shown on login
|
||||
useEffect(() => {
|
||||
api.getHelpStatus()
|
||||
.then(({ dismissed }) => {
|
||||
setHelpDismissed(dismissed);
|
||||
if (!dismissed) setModal('help');
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
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 / refresh push subscription
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
|
||||
const registerPush = async () => {
|
||||
try {
|
||||
const permission = Notification.permission;
|
||||
if (permission === 'denied') return;
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
|
||||
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
|
||||
if (!sub) {
|
||||
// First time or subscription was lost — request permission then subscribe
|
||||
const granted = permission === 'granted'
|
||||
? 'granted'
|
||||
: await Notification.requestPermission();
|
||||
if (granted !== 'granted') return;
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
}
|
||||
|
||||
// Always re-register subscription with the server (keeps it fresh on mobile)
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
});
|
||||
console.log('[Push] Subscription registered');
|
||||
} catch (e) {
|
||||
console.warn('[Push] Subscription failed:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
registerPush();
|
||||
|
||||
// Bug A fix: re-register push subscription when app returns to foreground
|
||||
// Mobile browsers can drop push subscriptions when the app is backgrounded
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'visible') registerPush();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||
}, []);
|
||||
|
||||
// Socket message events to update group previews
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleNewMsg = (msg) => {
|
||||
// Update group preview text
|
||||
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, last_message_user_id: msg.user_id }
|
||||
: g;
|
||||
const updatedPrivate = prev.privateGroups.map(updateGroup)
|
||||
.sort((a, b) => {
|
||||
if (!a.last_message_at && !b.last_message_at) return 0;
|
||||
if (!a.last_message_at) return 1;
|
||||
if (!b.last_message_at) return -1;
|
||||
return new Date(b.last_message_at) - new Date(a.last_message_at);
|
||||
});
|
||||
return {
|
||||
publicGroups: prev.publicGroups.map(updateGroup),
|
||||
privateGroups: updatedPrivate,
|
||||
};
|
||||
});
|
||||
// Don't badge own messages
|
||||
if (msg.user_id === user?.id) return;
|
||||
// Bug C fix: count unread even in the active group when window is hidden/minimized
|
||||
const groupIsActive = msg.group_id === activeGroupId;
|
||||
const windowHidden = document.visibilityState === 'hidden';
|
||||
setUnreadGroups(prev => {
|
||||
if (groupIsActive && !windowHidden) return prev; // visible & active: no badge
|
||||
const next = new Map(prev);
|
||||
next.set(msg.group_id, (next.get(msg.group_id) || 0) + 1);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleNotification = (notif) => {
|
||||
if (notif.type === 'private_message') {
|
||||
// Badge is already handled by handleNewMsg via message:new socket event.
|
||||
// Nothing to do here for the socket path.
|
||||
} else if (notif.type === 'support') {
|
||||
// A support request was submitted — reload groups so Support group appears in sidebar
|
||||
loadGroups();
|
||||
} 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);
|
||||
|
||||
// Group list real-time updates
|
||||
const handleGroupNew = ({ group }) => {
|
||||
// Join the socket room for this new group
|
||||
socket.emit('group:join-room', { groupId: group.id });
|
||||
// Reload the full group list so name/metadata is correct
|
||||
loadGroups();
|
||||
};
|
||||
const handleGroupDeleted = ({ groupId }) => {
|
||||
// Leave the socket room so we stop receiving events for this group
|
||||
socket.emit('group:leave-room', { groupId });
|
||||
setGroups(prev => ({
|
||||
publicGroups: prev.publicGroups.filter(g => g.id !== groupId),
|
||||
privateGroups: prev.privateGroups.filter(g => g.id !== groupId),
|
||||
}));
|
||||
setActiveGroupId(prev => {
|
||||
if (prev === groupId) {
|
||||
if (isMobile) setShowSidebar(true);
|
||||
return null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
setUnreadGroups(prev => { const next = new Map(prev); next.delete(groupId); return next; });
|
||||
};
|
||||
const handleGroupUpdated = ({ group }) => {
|
||||
setGroups(prev => {
|
||||
const update = g => g.id === group.id ? { ...g, ...group } : g;
|
||||
return {
|
||||
publicGroups: prev.publicGroups.map(update),
|
||||
privateGroups: prev.privateGroups.map(update),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Session displaced: another login on the same device type kicked us out
|
||||
const handleSessionDisplaced = ({ device: displacedDevice }) => {
|
||||
// Only act if it's our device slot that was taken over
|
||||
// (The server emits to user room so all sockets of this user receive it;
|
||||
// our socket's device is embedded in the socket but we can't read it here,
|
||||
// so we force logout unconditionally — the new session will reconnect cleanly)
|
||||
localStorage.removeItem('tc_token');
|
||||
sessionStorage.removeItem('tc_token');
|
||||
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
|
||||
};
|
||||
|
||||
// Online presence
|
||||
const handleUserOnline = ({ userId }) => setOnlineUserIds(prev => new Set([...prev, Number(userId)]));
|
||||
const handleUserOffline = ({ userId }) => setOnlineUserIds(prev => { const n = new Set(prev); n.delete(Number(userId)); return n; });
|
||||
const handleUsersOnline = ({ userIds }) => setOnlineUserIds(new Set((userIds || []).map(Number)));
|
||||
|
||||
socket.on('user:online', handleUserOnline);
|
||||
socket.on('user:offline', handleUserOffline);
|
||||
socket.on('users:online', handleUsersOnline);
|
||||
// Request current online list on connect
|
||||
socket.emit('users:online');
|
||||
|
||||
socket.on('group:new', handleGroupNew);
|
||||
socket.on('group:deleted', handleGroupDeleted);
|
||||
socket.on('group:updated', handleGroupUpdated);
|
||||
socket.on('session:displaced', handleSessionDisplaced);
|
||||
|
||||
// Bug B fix: on reconnect, reload groups to catch any messages missed while offline
|
||||
const handleReconnect = () => { loadGroups(); };
|
||||
socket.on('connect', handleReconnect);
|
||||
|
||||
// Bug B fix: also reload on visibility restore if socket is already connected
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'visible' && socket.connected) {
|
||||
loadGroups();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => {
|
||||
socket.off('message:new', handleNewMsg);
|
||||
socket.off('notification:new', handleNotification);
|
||||
socket.off('group:new', handleGroupNew);
|
||||
socket.off('group:deleted', handleGroupDeleted);
|
||||
socket.off('group:updated', handleGroupUpdated);
|
||||
socket.off('user:online', handleUserOnline);
|
||||
socket.off('user:offline', handleUserOffline);
|
||||
socket.off('users:online', handleUsersOnline);
|
||||
socket.off('connect', handleReconnect);
|
||||
socket.off('session:displaced', handleSessionDisplaced);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
|
||||
|
||||
const selectGroup = (id) => {
|
||||
setActiveGroupId(id);
|
||||
if (isMobile) {
|
||||
setShowSidebar(false);
|
||||
// Push a history entry so swipe-back returns to sidebar instead of exiting the app
|
||||
window.history.pushState({ jamaChatOpen: true }, '');
|
||||
}
|
||||
// Clear notifications and unread count for this group
|
||||
setNotifications(prev => prev.filter(n => n.groupId !== id));
|
||||
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
|
||||
};
|
||||
|
||||
// Handle browser back gesture on mobile — return to sidebar instead of exiting
|
||||
useEffect(() => {
|
||||
const handlePopState = (e) => {
|
||||
if (isMobile && activeGroupId) {
|
||||
setShowSidebar(true);
|
||||
setActiveGroupId(null);
|
||||
// Push another entry so subsequent back gestures are also intercepted
|
||||
window.history.pushState({ jamaChatOpen: true }, '');
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [isMobile, activeGroupId]);
|
||||
|
||||
// Update page title AND PWA app badge with total unread count
|
||||
useEffect(() => {
|
||||
const totalUnread = [...unreadGroups.values()].reduce((a, b) => a + b, 0);
|
||||
// Strip any existing badge prefix to get the clean base title
|
||||
const base = document.title.replace(/^\(\d+\)\s*/, '');
|
||||
document.title = totalUnread > 0 ? `(${totalUnread}) ${base}` : base;
|
||||
// PWA app icon badge (Chrome/Edge desktop + Android, Safari 16.4+)
|
||||
if ('setAppBadge' in navigator) {
|
||||
if (totalUnread > 0) {
|
||||
navigator.setAppBadge(totalUnread).catch(() => {});
|
||||
} else {
|
||||
navigator.clearAppBadge().catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [unreadGroups]);
|
||||
|
||||
const activeGroup = [
|
||||
...(groups.publicGroups || []),
|
||||
...(groups.privateGroups || [])
|
||||
].find(g => g.id === activeGroupId);
|
||||
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
|
||||
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} />
|
||||
|
||||
<div className="chat-body">
|
||||
{(!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')}
|
||||
onBranding={() => setModal('branding')}
|
||||
onGroupsUpdated={loadGroups}
|
||||
isMobile={isMobile}
|
||||
onAbout={() => setModal('about')}
|
||||
onHelp={() => setModal('help')}
|
||||
onlineUserIds={onlineUserIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!isMobile || !showSidebar) && (
|
||||
<ChatWindow
|
||||
group={activeGroup}
|
||||
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
|
||||
onGroupUpdated={loadGroups}
|
||||
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
|
||||
onlineUserIds={onlineUserIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/pages/Login.css
Normal file
@@ -0,0 +1,100 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
122
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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 || 'jama';
|
||||
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" />
|
||||
) : (
|
||||
<img src="/icons/jama.png" alt="jama" className="logo-img" />
|
||||
)}
|
||||
<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>ADMPW_RESET is enabled.</strong> The admin password is being reset on each restart. Disable ADMPW_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>
|
||||
);
|
||||
}
|
||||
130
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const BASE = '/api';
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||
}
|
||||
|
||||
// SQLite datetime('now') returns "YYYY-MM-DD HH:MM:SS" with no timezone marker.
|
||||
// Browsers parse bare strings like this as LOCAL time, but the value is actually UTC.
|
||||
// Appending 'Z' forces correct UTC interpretation so local display is always right.
|
||||
export function parseTS(ts) {
|
||||
if (!ts) return new Date(NaN);
|
||||
// Already has timezone info (contains T and Z/+ or ends in Z) — leave alone
|
||||
if (/Z$|[+-]\d{2}:\d{2}$/.test(ts) || (ts.includes('T') && ts.includes('Z'))) return new Date(ts);
|
||||
// Replace the space separator SQLite uses and append Z
|
||||
return new Date(ts.replace(' ', 'T') + 'Z');
|
||||
}
|
||||
|
||||
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) {
|
||||
// Session displaced by a new login elsewhere — force logout
|
||||
if (res.status === 401 && data.error?.includes('Session expired')) {
|
||||
localStorage.removeItem('tc_token');
|
||||
sessionStorage.removeItem('tc_token');
|
||||
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
|
||||
}
|
||||
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, groupId) => req('GET', `/users/search?q=${encodeURIComponent(q)}${groupId ? `&groupId=${groupId}` : ''}`),
|
||||
createUser: (body) => req('POST', '/users', body),
|
||||
bulkUsers: (users) => req('POST', '/users/bulk', { users }),
|
||||
updateName: (id, name) => req('PATCH', `/users/${id}/name`, { name }),
|
||||
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}`),
|
||||
checkDisplayName: (name) => req('GET', `/users/check-display-name?name=${encodeURIComponent(name)}`),
|
||||
updateProfile: (body) => req('PATCH', '/users/me/profile', body), // body: { displayName, aboutMe, hideAdminTag, allowDm }
|
||||
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 }),
|
||||
setCustomGroupName: (id, name) => req('PATCH', `/groups/${id}/custom-name`, { name }),
|
||||
getHelp: () => req('GET', '/help'),
|
||||
getHelpStatus: () => req('GET', '/help/status'),
|
||||
dismissHelp: (dismissed) => req('POST', '/help/dismiss', { dismissed }),
|
||||
getMembers: (id) => req('GET', `/groups/${id}/members`),
|
||||
addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }),
|
||||
removeMember: (groupId, userId) => req('DELETE', `/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 }),
|
||||
updateColors: (body) => req('PATCH', '/settings/colors', body),
|
||||
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)}`),
|
||||
|
||||
|
||||
|
||||
// VAPID key management (admin only)
|
||||
generateVapidKeys: () => req('POST', '/push/generate-vapid'),
|
||||
};
|
||||
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 }
|
||||
}
|
||||
}
|
||||
});
|
||||