v0.3.0
This commit is contained in:
87
frontend/src/components/AboutModal.jsx
Normal file
87
frontend/src/components/AboutModal.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
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 [settings, setSettings] = useState({ app_name: 'jama', app_version: '' });
|
||||
const [about, setAbout] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
fetch('/api/about')
|
||||
.then(r => r.json())
|
||||
.then(({ about }) => setAbout(about))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const appName = settings.app_name || 'jama';
|
||||
// Version always mirrors Settings window — from settings API (env var)
|
||||
const version = settings.app_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="/icons/jama.png" alt="jama" 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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
background: var(--surface-variant);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-window.empty {
|
||||
@@ -79,11 +81,16 @@
|
||||
/* 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;
|
||||
/* Anchor scroll to bottom so new messages appear above the input */
|
||||
scroll-padding-bottom: 0;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
|
||||
@@ -8,7 +8,7 @@ import MessageInput from './MessageInput.jsx';
|
||||
import GroupInfoModal from './GroupInfoModal.jsx';
|
||||
import './ChatWindow.css';
|
||||
|
||||
export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage }) {
|
||||
const { socket } = useSocket();
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
@@ -23,6 +23,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesTopRef = useRef(null);
|
||||
const typingTimers = useRef({});
|
||||
const swipeStartX = useRef(null);
|
||||
const swipeStartY = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
@@ -33,6 +35,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
return () => window.removeEventListener('jama:settings-changed', handler);
|
||||
}, []);
|
||||
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
||||
}, []);
|
||||
@@ -110,7 +113,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
setHasMore(older.length >= 50);
|
||||
};
|
||||
|
||||
const handleSend = async ({ content, imageFile, linkPreview }) => {
|
||||
const handleSend = async ({ content, imageFile, linkPreview, emojiOnly }) => {
|
||||
if (!group) return;
|
||||
const replyId = replyTo?.id;
|
||||
setReplyTo(null);
|
||||
@@ -125,7 +128,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
}
|
||||
} else {
|
||||
socket?.emit('message:send', {
|
||||
groupId: group.id, content, replyToId: replyId, linkPreview
|
||||
groupId: group.id, content, replyToId: replyId, linkPreview, emojiOnly
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -149,8 +152,30 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
);
|
||||
}
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
swipeStartX.current = e.touches[0].clientX;
|
||||
swipeStartY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
if (swipeStartX.current === null || !onBack) return;
|
||||
const dx = e.changedTouches[0].clientX - swipeStartX.current;
|
||||
const dy = Math.abs(e.changedTouches[0].clientY - swipeStartY.current);
|
||||
// Swipe right: at least 80px horizontal, less than 60px vertical drift
|
||||
if (dx > 80 && dy < 60) {
|
||||
e.preventDefault();
|
||||
onBack();
|
||||
}
|
||||
swipeStartX.current = null;
|
||||
swipeStartY.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<div
|
||||
className="chat-window"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="chat-header">
|
||||
{onBack && (
|
||||
@@ -172,12 +197,12 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
) : null}
|
||||
</div>
|
||||
<span className="chat-header-sub">
|
||||
{group.type === 'public' ? 'Public group' : 'Private group'}
|
||||
{group.is_direct ? 'Direct message' : group.type === 'public' ? 'Public message' : 'Private message'}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info">
|
||||
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Message info">
|
||||
{iconGroupInfo ? (
|
||||
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
<img src={iconGroupInfo} alt="Message info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="24" height="24">
|
||||
<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" />
|
||||
@@ -203,9 +228,11 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
message={msg}
|
||||
prevMessage={messages[i - 1]}
|
||||
currentUser={user}
|
||||
isDirect={!!group.is_direct}
|
||||
onReply={(m) => setReplyTo(m)}
|
||||
onDelete={(id) => socket?.emit('message:delete', { messageId: id })}
|
||||
onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })}
|
||||
onDirectMessage={onDirectMessage}
|
||||
/>
|
||||
))}
|
||||
{typing.length > 0 && (
|
||||
@@ -236,7 +263,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
) : (
|
||||
<div className="readonly-bar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
This channel is read-only
|
||||
This message is read-only
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -245,6 +272,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
group={group}
|
||||
onClose={() => setShowInfo(false)}
|
||||
onUpdated={onGroupUpdated}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
41
frontend/src/components/GlobalBar.jsx
Normal file
41
frontend/src/components/GlobalBar.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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: '' });
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
window.addEventListener('jama:settings-changed', handler);
|
||||
return () => window.removeEventListener('jama:settings-changed', handler);
|
||||
}, []);
|
||||
|
||||
const appName = settings.app_name || 'jama';
|
||||
const logoUrl = settings.logo_url;
|
||||
|
||||
// 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">{appName}</span>
|
||||
</div>
|
||||
{!connected && (
|
||||
<span className="global-bar-offline" title="Offline">
|
||||
<span className="offline-dot" />
|
||||
<span className="offline-label">Offline</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [members, setMembers] = useState([]);
|
||||
@@ -12,12 +12,12 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
const [newName, setNewName] = useState(group.name);
|
||||
const [addSearch, setAddSearch] = useState('');
|
||||
const [addResults, setAddResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isDirect = !!group.is_direct;
|
||||
const isOwner = group.owner_id === user.id;
|
||||
const isAdmin = user.role === 'admin';
|
||||
const canManage = (group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin);
|
||||
const canRename = !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner));
|
||||
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') {
|
||||
@@ -35,24 +35,30 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
if (!newName.trim() || newName === group.name) { setEditing(false); return; }
|
||||
try {
|
||||
await api.renameGroup(group.id, newName.trim());
|
||||
toast('Group renamed', 'success');
|
||||
toast('Renamed', 'success');
|
||||
onUpdated();
|
||||
setEditing(false);
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!confirm('Leave this group?')) return;
|
||||
if (!confirm('Leave this message?')) return;
|
||||
try {
|
||||
await api.leaveGroup(group.id);
|
||||
toast('Left group', 'success');
|
||||
onUpdated();
|
||||
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? You will be able to see all messages.')) return;
|
||||
if (!confirm('Take ownership of this private group?')) return;
|
||||
try {
|
||||
await api.takeOwnership(group.id);
|
||||
toast('Ownership taken', 'success');
|
||||
@@ -64,7 +70,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
const handleAdd = async (u) => {
|
||||
try {
|
||||
await api.addMember(group.id, u.id);
|
||||
toast(`${u.display_name || u.name} added`, 'success');
|
||||
toast(`${u.name} added`, 'success');
|
||||
api.getMembers(group.id).then(({ members }) => setMembers(members));
|
||||
setAddSearch('');
|
||||
setAddResults([]);
|
||||
@@ -72,29 +78,34 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
};
|
||||
|
||||
const handleRemove = async (member) => {
|
||||
if (!confirm(`Remove ${member.display_name || member.name} from this group?`)) return;
|
||||
if (!confirm(`Remove ${member.name}?`)) return;
|
||||
try {
|
||||
await api.removeMember(group.id, member.id);
|
||||
toast(`${member.display_name || member.name} removed`, 'success');
|
||||
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 group? This cannot be undone.')) return;
|
||||
if (!confirm('Delete this message? This cannot be undone.')) return;
|
||||
try {
|
||||
await api.deleteGroup(group.id);
|
||||
toast('Group deleted', 'success');
|
||||
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 }}>Group Info</h2>
|
||||
<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>
|
||||
@@ -120,14 +131,14 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
)}
|
||||
<div className="flex items-center gap-6" style={{ gap: 8, marginTop: 4 }}>
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{group.type === 'public' ? 'Public channel' : 'Private group'}
|
||||
{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>}
|
||||
{!!group.is_readonly && <span className="readonly-badge" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: '#fff3e0', color: '#e65100' }}>Read-only</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members (private groups) */}
|
||||
{group.type === 'private' && (
|
||||
{/* 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})
|
||||
@@ -136,18 +147,13 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
{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.display_name || m.name}</span>
|
||||
<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 from group"
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-tertiary)', padding: '2px 4px', borderRadius: 4,
|
||||
lineHeight: 1, fontSize: 16,
|
||||
transition: 'color var(--transition)',
|
||||
}}
|
||||
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)'}
|
||||
>
|
||||
@@ -159,16 +165,15 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
</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' }}>
|
||||
<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)' }} onClick={() => handleAdd(u)} onMouseEnter={e => e.currentTarget.style.background = 'var(--background)'} onMouseLeave={e => e.currentTarget.style.background = ''}>
|
||||
<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.display_name || u.name}</span>
|
||||
<span className="text-sm flex-1">{u.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -180,16 +185,21 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex-col gap-2">
|
||||
{group.type === 'private' && group.owner_id !== user.id && (
|
||||
{/* 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>
|
||||
)}
|
||||
{isAdmin && group.type === 'private' && group.owner_id !== user.id && (
|
||||
<button className="btn btn-secondary w-full" onClick={handleTakeOwnership}>
|
||||
Take Ownership (Admin)
|
||||
</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>
|
||||
)}
|
||||
{(isOwner || (isAdmin && group.type === 'public')) && !group.is_default && (
|
||||
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete Group</button>
|
||||
{/* Delete */}
|
||||
{(canDeleteDirect || canDeleteRegular) && (
|
||||
<button className="btn btn-danger w-full" onClick={handleDelete}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,44 @@
|
||||
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-end;
|
||||
@@ -137,6 +175,13 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.msg-bubble {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-bubble.out {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
@@ -144,7 +189,7 @@
|
||||
}
|
||||
|
||||
.msg-bubble.in {
|
||||
background: white;
|
||||
background: var(--bubble-in);
|
||||
color: var(--text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
@@ -264,3 +309,19 @@
|
||||
.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: 48px;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@@ -4,16 +4,34 @@ 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 '';
|
||||
return content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `<span class="mention">@${name}</span>`);
|
||||
// First handle @mentions
|
||||
let html = content.replace(/@\[([^\]]+)\]\(\d+\)/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;
|
||||
}
|
||||
|
||||
export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact }) {
|
||||
|
||||
// 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 }) {
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const wrapperRef = useRef(null);
|
||||
@@ -25,21 +43,36 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
|
||||
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 = prevMessage && prevMessage.user_id === msg.user_id &&
|
||||
prevMessage.type !== 'system' && msg.type !== 'system' &&
|
||||
parseTS(msg.created_at) - parseTS(prevMessage.created_at) < 60000;
|
||||
|
||||
const canDelete = !msg.is_deleted && (
|
||||
msg.user_id === currentUser.id ||
|
||||
currentUser.role === 'admin' ||
|
||||
msg.group_owner_id === currentUser.id
|
||||
);
|
||||
|
||||
// Deleted messages are filtered out by ChatWindow, but guard here too
|
||||
if (isDeleted) return null;
|
||||
|
||||
const canDelete = (
|
||||
msg.user_id === currentUser.id ||
|
||||
currentUser.role === 'admin' ||
|
||||
(msg.group_owner_id === currentUser.id)
|
||||
);
|
||||
|
||||
const prevSameUser = prevMessage && prevMessage.user_id === msg.user_id &&
|
||||
new Date(msg.created_at) - new Date(prevMessage.created_at) < 60000;
|
||||
|
||||
const showDateSep = !prevMessage ||
|
||||
new Date(msg.created_at).toDateString() !== new Date(prevMessage.created_at).toDateString();
|
||||
// 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 || [])) {
|
||||
@@ -66,6 +99,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!msg.content) return;
|
||||
navigator.clipboard.writeText(msg.content).catch(() => {});
|
||||
};
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showEmojiPicker && wrapperRef.current) {
|
||||
// If the message is in the top 400px of viewport, open picker downward
|
||||
@@ -97,11 +135,15 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={`message-wrapper ${isOwn ? 'own' : 'other'} ${prevSameUser ? 'grouped' : ''}`}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
|
||||
>
|
||||
{!isOwn && !prevSameUser && (
|
||||
<div ref={avatarRef} style={{ cursor: 'pointer' }} onClick={() => setShowProfile(p => !p)}>
|
||||
<div
|
||||
ref={avatarRef}
|
||||
style={{ cursor: 'pointer', borderRadius: '50%', transition: 'box-shadow 0.15s' }}
|
||||
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" />
|
||||
</div>
|
||||
)}
|
||||
@@ -133,7 +175,10 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
|
||||
{/* Bubble + actions together so actions hover above bubble */}
|
||||
<div className="msg-bubble-wrap">
|
||||
<div className="msg-bubble-with-actions">
|
||||
<div className="msg-bubble-with-actions"
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }}
|
||||
>
|
||||
{/* Actions toolbar — floats above the bubble, aligned to correct side */}
|
||||
{!isDeleted && (showActions || showEmojiPicker) && (
|
||||
<div className={`msg-actions ${isOwn ? 'actions-left' : 'actions-right'}`}>
|
||||
@@ -146,6 +191,11 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
<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>
|
||||
@@ -165,7 +215,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}`}>
|
||||
<div className={`msg-bubble ${isOwn ? 'out' : 'in'}${!msg.image_url && isEmojiOnly(msg.content) ? ' emoji-only' : ''}`}>
|
||||
{msg.image_url && (
|
||||
<img
|
||||
src={msg.image_url}
|
||||
@@ -175,10 +225,12 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
/>
|
||||
)}
|
||||
{msg.content && (
|
||||
<p
|
||||
className="msg-text"
|
||||
dangerouslySetInnerHTML={{ __html: formatMsgContent(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>
|
||||
@@ -209,6 +261,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
user={msgUser}
|
||||
anchorEl={avatarRef.current}
|
||||
onClose={() => setShowProfile(false)}
|
||||
onDirectMessage={onDirectMessage}
|
||||
/>
|
||||
)}
|
||||
{lightboxSrc && (
|
||||
@@ -236,11 +289,11 @@ function LinkPreview({ data: raw }) {
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return parseTS(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(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);
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.reply-bar-input {
|
||||
@@ -133,7 +137,7 @@
|
||||
.msg-input {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
max-height: calc(1.4em * 5 + 20px); /* 5 lines × line-height + padding */
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
@@ -143,9 +147,10 @@
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-variant);
|
||||
transition: border-color var(--transition);
|
||||
overflow-y: auto;
|
||||
overflow-y: hidden;
|
||||
resize: none;
|
||||
}
|
||||
.msg-input:focus { outline: none; border-color: var(--primary); background: white; }
|
||||
.msg-input:focus { outline: none; border-color: var(--primary); background: var(--surface-variant); }
|
||||
.msg-input::placeholder { color: var(--text-tertiary); }
|
||||
|
||||
.send-btn {
|
||||
@@ -166,3 +171,68 @@
|
||||
}
|
||||
.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) {
|
||||
.input-row {
|
||||
min-width: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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 }) {
|
||||
const [text, setText] = useState('');
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
@@ -14,11 +22,30 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
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) => {
|
||||
@@ -35,13 +62,26 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Link preview
|
||||
// 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 {}
|
||||
} catch {
|
||||
clearTimeout(abandonTimer);
|
||||
}
|
||||
setLoadingPreview(false);
|
||||
}, []);
|
||||
|
||||
@@ -50,7 +90,13 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
setText(val);
|
||||
handleTypingChange(val);
|
||||
|
||||
// Detect @mention
|
||||
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) {
|
||||
@@ -68,7 +114,6 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
}
|
||||
setShowMention(false);
|
||||
|
||||
// Link preview
|
||||
const urls = val.match(URL_REGEX);
|
||||
if (urls && urls[0] !== linkPreview?.url) {
|
||||
fetchPreview(urls[0]);
|
||||
@@ -112,20 +157,33 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
setImagePreview(null);
|
||||
wasTyping.current = false;
|
||||
onTyping(false);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
inputRef.current.style.overflowY = 'hidden';
|
||||
}
|
||||
|
||||
await onSend({ content: trimmed || null, imageFile, linkPreview: lp });
|
||||
// Tag emoji-only messages so they can be rendered large
|
||||
const emojiOnly = !!trimmed && isEmojiOnly(trimmed);
|
||||
await onSend({ content: trimmed || null, imageFile, linkPreview: lp, emojiOnly });
|
||||
};
|
||||
|
||||
// Send a single emoji directly (from picker)
|
||||
const handleEmojiSend = async (emoji) => {
|
||||
setShowEmojiPicker(false);
|
||||
await onSend({ content: emoji.native, imageFile: null, linkPreview: null, emojiOnly: true });
|
||||
};
|
||||
|
||||
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 enough — still re-encode to strip EXIF and reduce size
|
||||
// already small
|
||||
} else {
|
||||
const ratio = Math.min(MAX_PX / width, MAX_PX / height);
|
||||
width = Math.round(width * ratio);
|
||||
@@ -134,10 +192,17 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(blob => {
|
||||
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
|
||||
}, 'image/jpeg', QUALITY);
|
||||
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;
|
||||
});
|
||||
@@ -150,12 +215,11 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setImagePreview(e.target.result);
|
||||
reader.readAsDataURL(compressed);
|
||||
setShowAttachMenu(false);
|
||||
};
|
||||
|
||||
const displayText = (t) => {
|
||||
// Convert @[name](id) to @name for display
|
||||
return t.replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1');
|
||||
};
|
||||
// Detect mobile (touch device)
|
||||
const isMobile = () => window.matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
return (
|
||||
<div className="message-input-area">
|
||||
@@ -215,10 +279,59 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
)}
|
||||
|
||||
<div className="input-row">
|
||||
<button className="btn-icon input-action" onClick={() => fileInput.current?.click()} title="Attach image">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</button>
|
||||
|
||||
{/* + 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={handleEmojiSend}
|
||||
theme="light"
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
maxFrequentRows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="input-wrap">
|
||||
<textarea
|
||||
@@ -234,12 +347,15 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`send-btn ${(text.trim() || imageFile) ? 'active' : ''}`}
|
||||
className={`send-btn ${(text.trim() || imageFile) && !loadingPreview ? 'active' : ''}`}
|
||||
onClick={handleSend}
|
||||
disabled={!text.trim() && !imageFile}
|
||||
title="Send"
|
||||
disabled={(!text.trim() && !imageFile) || loadingPreview}
|
||||
title={loadingPreview ? 'Loading preview…' : 'Send'}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
{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>
|
||||
|
||||
@@ -15,6 +15,9 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
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(() => {});
|
||||
}, []);
|
||||
@@ -25,18 +28,37 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
}
|
||||
}, [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 (!name.trim()) return toast('Name required', 'error');
|
||||
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 {
|
||||
const { group } = await api.createGroup({
|
||||
name: name.trim(),
|
||||
type: tab,
|
||||
memberIds: selected.map(u => u.id),
|
||||
isReadonly: tab === 'public' && isReadonly,
|
||||
});
|
||||
toast(`${tab === 'public' ? 'Channel' : 'Chat'} created!`, 'success');
|
||||
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 } = await api.createGroup(payload);
|
||||
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
|
||||
onCreated(group);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
@@ -45,10 +67,10 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
};
|
||||
// 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()}>
|
||||
@@ -62,49 +84,66 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||
<button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Private Group</button>
|
||||
<button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Channel</button>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{tab === 'public' ? 'Channel Name' : 'Group Name'}
|
||||
</label>
|
||||
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder={tab === 'public' ? 'e.g. Announcements' : 'e.g. Project Team'} autoFocus />
|
||||
</div>
|
||||
{/* Message Name — hidden for direct (1-user) messages */}
|
||||
{!isDirect && (
|
||||
<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}
|
||||
autoFocus={tab === 'public'}
|
||||
/>
|
||||
</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 channel (only admins can post)
|
||||
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)' }}>Add Members</label>
|
||||
<input className="input" placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<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)} autoFocus />
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="flex gap-2" style={{ flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{selected.map(u => (
|
||||
<span key={u.id} className="chip">
|
||||
{u.display_name || u.name}
|
||||
{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).map(u => (
|
||||
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
|
||||
<Avatar user={u} size="sm" />
|
||||
<span className="flex-1 text-sm">{u.display_name || u.name}</span>
|
||||
<span className="flex-1 text-sm">{u.name}</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{u.role}</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -115,7 +154,7 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
<div className="flex gap-2 justify-between" style={{ marginTop: 20 }}>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
{loading ? 'Creating...' : isDirect ? 'Start Conversation' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function ProfileModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
|
||||
const [displayName, setDisplayName] = useState(user?.display_name || '');
|
||||
const [displayNameWarning, setDisplayNameWarning] = useState('');
|
||||
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
|
||||
const [currentPw, setCurrentPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
@@ -18,6 +19,7 @@ export default function ProfileModal({ onClose }) {
|
||||
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
||||
|
||||
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 });
|
||||
@@ -98,7 +100,24 @@ export default function ProfileModal({ onClose }) {
|
||||
<div className="flex-col gap-3">
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Display Name</label>
|
||||
<input className="input" value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={user?.name} />
|
||||
<input
|
||||
className="input"
|
||||
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 }}
|
||||
/>
|
||||
{displayNameWarning && <span className="text-xs" style={{ color: '#e53935' }}>{displayNameWarning}</span>}
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
height: 100vh;
|
||||
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) {
|
||||
@@ -17,8 +19,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 12px;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
@@ -28,42 +31,68 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
padding: 12px 12px 8px;
|
||||
/* New Chat bar (desktop) */
|
||||
.sidebar-newchat-bar {
|
||||
padding: 10px 12px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
.newchat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 36px;
|
||||
border: none;
|
||||
padding: 9px 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--background);
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font);
|
||||
font-weight: 600;
|
||||
border: 1.5px solid var(--primary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.search-input:focus { outline: none; }
|
||||
.search-input::placeholder { color: var(--text-tertiary); }
|
||||
.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;
|
||||
@@ -88,7 +117,6 @@
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
border-radius: 0;
|
||||
}
|
||||
.group-item:hover { background: var(--background); }
|
||||
.group-item.active { background: var(--primary-light); }
|
||||
@@ -131,11 +159,12 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.sidebar-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
padding: 12px 8px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-footer-btn {
|
||||
@@ -179,24 +208,8 @@
|
||||
.footer-menu-item.danger { color: var(--error); }
|
||||
.footer-menu-item.danger:hover { background: #fce8e6; }
|
||||
|
||||
/* App logo in sidebar header */
|
||||
.sidebar-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Unread message indicator */
|
||||
.group-item.has-unread {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
.unread-name {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.group-item.has-unread { background: var(--primary-light); }
|
||||
.unread-name { font-weight: 700; color: var(--text-primary) !important; }
|
||||
.badge-unread {
|
||||
background: var(--text-secondary);
|
||||
color: white;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import { api, parseTS } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import './Sidebar.css';
|
||||
@@ -26,41 +26,50 @@ function useAppSettings() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
// Re-fetch when settings are saved from the SettingsModal
|
||||
window.addEventListener('jama:settings-changed', fetchSettings);
|
||||
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
|
||||
}, []);
|
||||
|
||||
// Update page title and favicon whenever settings change
|
||||
useEffect(() => {
|
||||
const name = settings.app_name || 'jama';
|
||||
|
||||
// Update <title>
|
||||
document.title = name;
|
||||
|
||||
// Update favicon
|
||||
const logoUrl = settings.logo_url;
|
||||
const faviconUrl = logoUrl || '/icons/jama.png';
|
||||
let link = document.querySelector("link[rel~='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
|
||||
link.href = faviconUrl;
|
||||
}, [settings]);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) {
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated, isMobile, onAbout }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { connected } = useSocket();
|
||||
const toast = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const settings = useAppSettings();
|
||||
const [dark, setDark] = useTheme();
|
||||
const menuRef = useRef(null);
|
||||
const footerBtnRef = useRef(null);
|
||||
|
||||
// Fix 6: swipe right to go back on mobile — handled in ChatWindow, but prevent sidebar swipe exit
|
||||
// Close menu on click outside
|
||||
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 appName = settings.app_name || 'jama';
|
||||
const logoUrl = settings.logo_url;
|
||||
@@ -70,18 +79,12 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
...(groups.privateGroups || [])
|
||||
];
|
||||
|
||||
const filtered = search
|
||||
? allGroups.filter(g => g.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: allGroups;
|
||||
|
||||
const publicFiltered = filtered.filter(g => g.type === 'public');
|
||||
const privateFiltered = filtered.filter(g => g.type === 'private');
|
||||
const publicFiltered = allGroups.filter(g => g.type === 'public');
|
||||
const privateFiltered = allGroups.filter(g => g.type === 'private');
|
||||
|
||||
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
const handleLogout = async () => { await logout(); };
|
||||
|
||||
const GroupItem = ({ group }) => {
|
||||
const notifs = getNotifCount(group.id);
|
||||
@@ -115,36 +118,16 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
{/* Header with live app name and logo */}
|
||||
<div className="sidebar-header">
|
||||
<div className="flex items-center gap-2 flex-1" style={{ minWidth: 0 }}>
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={appName} className="sidebar-logo" />
|
||||
) : (
|
||||
<img src="/icons/jama.png" alt="jama" className="sidebar-logo" />
|
||||
)}
|
||||
<h2 className="sidebar-title truncate">{appName}</h2>
|
||||
{!connected && <span className="offline-dot" title="Offline" />}
|
||||
</div>
|
||||
<button className="btn-icon" onClick={onNewChat} title="New Chat">
|
||||
{settings.icon_newchat ? (
|
||||
<img src={settings.icon_newchat} alt="New Chat" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
{/* New Chat button replacing search bar */}
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="sidebar-search">
|
||||
<div className="search-wrap">
|
||||
<svg className="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input className="search-input" placeholder="Search chats..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
New Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Groups list */}
|
||||
@@ -155,25 +138,32 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{privateFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">DIRECT MESSAGES</div>
|
||||
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
{allGroups.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
|
||||
No chats found
|
||||
No chats yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB: New Chat button floats above user footer */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* User footer */}
|
||||
<div className="sidebar-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
|
||||
<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>
|
||||
@@ -190,7 +180,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
style={{ flexShrink: 0, padding: 8 }}
|
||||
>
|
||||
{dark ? (
|
||||
/* Sun icon — click to go light */
|
||||
<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"/>
|
||||
@@ -199,7 +188,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
<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>
|
||||
) : (
|
||||
/* Moon icon — click to go dark */
|
||||
<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>
|
||||
@@ -208,24 +196,29 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
</div>
|
||||
|
||||
{showMenu && (
|
||||
<div className="footer-menu" onClick={() => setShowMenu(false)}>
|
||||
<button className="footer-menu-item" onClick={onProfile}>
|
||||
<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={onUsers}>
|
||||
<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={onOpenSettings}>
|
||||
<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); 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
|
||||
@@ -239,7 +232,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const date = parseTS(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
if (diff < 86400000 && date.getDate() === now.getDate()) {
|
||||
@@ -250,3 +243,4 @@ function formatTime(dateStr) {
|
||||
}
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,229 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
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>
|
||||
{!!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); } }}
|
||||
autoFocus
|
||||
/>
|
||||
<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(''); } }}
|
||||
autoFocus
|
||||
/>
|
||||
<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 { user: me } = useAuth();
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tab, setTab] = useState('users'); // 'users' | 'create' | 'bulk'
|
||||
const [tab, setTab] = useState('users');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'member' });
|
||||
const [bulkPreview, setBulkPreview] = useState([]);
|
||||
|
||||
const [csvFile, setCsvFile] = useState(null);
|
||||
const [csvRows, setCsvRows] = useState([]);
|
||||
const [csvInvalid, setCsvInvalid] = useState([]);
|
||||
const [bulkResult, setBulkResult] = useState(null);
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const [resetingId, setResetingId] = useState(null);
|
||||
const [resetPw, setResetPw] = useState('');
|
||||
|
||||
const fileRef = useRef(null);
|
||||
const [userPass, setUserPass] = useState('user@1234');
|
||||
|
||||
const load = () => {
|
||||
api.getUsers().then(({ users }) => setUsers(users)).catch(() => {}).finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
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 || !form.email || !form.password) return toast('All fields required', 'error');
|
||||
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);
|
||||
@@ -46,31 +238,28 @@ export default function UserManagerModal({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCSV = (e) => {
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
complete: ({ data }) => {
|
||||
const rows = data.filter(r => r.email).map(r => ({
|
||||
name: r.name || r.Name || '',
|
||||
email: r.email || r.Email || '',
|
||||
password: r.password || r.Password || 'TempPass@123',
|
||||
role: (r.role || r.Role || 'member').toLowerCase(),
|
||||
}));
|
||||
setBulkPreview(rows);
|
||||
}
|
||||
});
|
||||
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 (!bulkPreview.length) return;
|
||||
if (!csvRows.length) return;
|
||||
setBulkLoading(true);
|
||||
try {
|
||||
const results = await api.bulkUsers(bulkPreview);
|
||||
toast(`Created: ${results.created.length}, Errors: ${results.errors.length}`, results.errors.length ? 'default' : 'success');
|
||||
setBulkPreview([]);
|
||||
setTab('users');
|
||||
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');
|
||||
@@ -79,48 +268,9 @@ export default function UserManagerModal({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRole = async (u, role) => {
|
||||
try {
|
||||
await api.updateRole(u.id, role);
|
||||
toast('Role updated', 'success');
|
||||
load();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPw = async (uid) => {
|
||||
if (!resetPw || resetPw.length < 6) return toast('Enter a password (min 6 chars)', 'error');
|
||||
try {
|
||||
await api.resetPassword(uid, resetPw);
|
||||
toast('Password reset — user must change on next login', 'success');
|
||||
setResetingId(null);
|
||||
setResetPw('');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuspend = async (u) => {
|
||||
if (!confirm(`Suspend ${u.name}?`)) return;
|
||||
try { await api.suspendUser(u.id); toast('User suspended', 'success'); load(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleActivate = async (u) => {
|
||||
try { await api.activateUser(u.id); toast('User activated', 'success'); load(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (u) => {
|
||||
if (!confirm(`Delete ${u.name}? Their messages will remain but they cannot log in.`)) return;
|
||||
try { await api.deleteUser(u.id); toast('User deleted', 'success'); load(); }
|
||||
catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 700 }}>
|
||||
<div className="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}>
|
||||
@@ -128,78 +278,22 @@ export default function UserManagerModal({ onClose }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||
<button className={`btn btn-sm ${tab === 'users' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('users')}>
|
||||
All Users ({users.length})
|
||||
</button>
|
||||
<button className={`btn btn-sm ${tab === 'create' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('create')}>
|
||||
+ Create User
|
||||
</button>
|
||||
<button className={`btn btn-sm ${tab === 'bulk' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('bulk')}>
|
||||
Bulk Import CSV
|
||||
</button>
|
||||
<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 */}
|
||||
{/* Users list — accordion */}
|
||||
{tab === 'users' && (
|
||||
<>
|
||||
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<input className="input" style={{ marginBottom: 12 }} placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
{loading ? (
|
||||
<div className="flex justify-center" style={{ padding: 40 }}><div className="spinner" /></div>
|
||||
) : (
|
||||
<div style={{ maxHeight: 440, overflowY: 'auto' }}>
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{filtered.map(u => (
|
||||
<div key={u.id} style={{ borderBottom: '1px solid var(--border)', padding: '12px 0' }}>
|
||||
<div className="flex items-center gap-2" style={{ gap: 12 }}>
|
||||
<Avatar user={u} size="sm" />
|
||||
<div className="flex-col flex-1 overflow-hidden">
|
||||
<div className="flex items-center gap-2" style={{ gap: 8 }}>
|
||||
<span className="font-medium text-sm">{u.display_name || u.name}</span>
|
||||
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
||||
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
|
||||
{u.is_default_admin ? <span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>Default Admin</span> : null}
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{u.email}</span>
|
||||
{u.must_change_password ? <span className="text-xs" style={{ color: 'var(--warning)' }}>⚠ Must change password</span> : null}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!u.is_default_admin && (
|
||||
<div className="flex gap-1" style={{ gap: 4 }}>
|
||||
{resetingId === u.id ? (
|
||||
<div className="flex gap-1" style={{ gap: 4 }}>
|
||||
<input className="input" style={{ width: 130, fontSize: 12, padding: '4px 8px' }} type="password" placeholder="New password" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleResetPw(u.id)}>Set</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setResetingId(null); setResetPw(''); }}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setResetingId(u.id)} title="Reset password">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
Reset PW
|
||||
</button>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => handleRole(u, e.target.value)}
|
||||
className="input"
|
||||
style={{ width: 90, padding: '4px 6px', fontSize: 12 }}
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
{u.status === 'active' ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleSuspend(u)}>Suspend</button>
|
||||
) : u.status === 'suspended' ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleActivate(u)} style={{ color: 'var(--success)' }}>Activate</button>
|
||||
) : null}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(u)}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<UserRow key={u.id} u={u} onUpdated={load} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -209,20 +303,20 @@ export default function UserManagerModal({ onClose }) {
|
||||
{/* Create user */}
|
||||
{tab === 'create' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div className="flex gap-3" style={{ gap: 12 }}>
|
||||
<div className="flex-col gap-1 flex-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Full Name</label>
|
||||
<input className="input" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))} />
|
||||
<div 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 flex-1">
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Email</label>
|
||||
<input className="input" type="email" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||
<input className="input" type="email" placeholder="jane@example.com" value={form.email} onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3" style={{ gap: 12 }}>
|
||||
<div className="flex-col gap-1 flex-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Temp Password</label>
|
||||
<input className="input" type="password" value={form.password} onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
|
||||
<div 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 = default)</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>
|
||||
@@ -232,8 +326,8 @@ export default function UserManagerModal({ onClose }) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>User will be required to change their password on first login.</p>
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={creating}>{creating ? 'Creating...' : 'Create User'}</button>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -241,44 +335,65 @@ export default function UserManagerModal({ onClose }) {
|
||||
{tab === 'bulk' && (
|
||||
<div className="flex-col gap-4">
|
||||
<div className="card" style={{ background: 'var(--background)', border: '1px dashed var(--border)' }}>
|
||||
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>CSV Format</p>
|
||||
<code style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'block', background: 'white', padding: 8, borderRadius: 4, border: '1px solid var(--border)' }}>
|
||||
name,email,password,role{'\n'}
|
||||
John Doe,john@example.com,TempPass123,member
|
||||
</code>
|
||||
<p className="text-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 }}>
|
||||
role can be "member" or "admin". Password defaults to TempPass@123 if omitted. All users must change password on first login.
|
||||
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>
|
||||
|
||||
<label className="btn btn-secondary pointer" style={{ alignSelf: 'flex-start' }}>
|
||||
Select CSV File
|
||||
<input ref={fileRef} type="file" accept=".csv" style={{ display: 'none' }} onChange={handleCSV} />
|
||||
</label>
|
||||
|
||||
{bulkPreview.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ marginBottom: 8 }}>Preview ({bulkPreview.length} users)</p>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', maxHeight: 200, overflowY: 'auto' }}>
|
||||
{bulkPreview.slice(0, 10).map((u, i) => (
|
||||
<div key={i} className="flex items-center gap-2" style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 13, gap: 12 }}>
|
||||
<span className="flex-1">{u.name}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{u.email}</span>
|
||||
<span className={`role-badge role-${u.role}`}>{u.role}</span>
|
||||
</div>
|
||||
))}
|
||||
{bulkPreview.length > 10 && (
|
||||
<div style={{ padding: '8px 12px', color: 'var(--text-tertiary)', fontSize: 13 }}>
|
||||
...and {bulkPreview.length - 10} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleBulkImport} disabled={bulkLoading}>
|
||||
{bulkLoading ? 'Importing...' : `Import ${bulkPreview.length} Users`}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
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, anchorEl, onClose }) {
|
||||
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) => {
|
||||
@@ -15,7 +21,6 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [anchorEl, onClose]);
|
||||
|
||||
// Position near the anchor element
|
||||
useEffect(() => {
|
||||
if (!popupRef.current || !anchorEl) return;
|
||||
const anchor = anchorEl.getBoundingClientRect();
|
||||
@@ -23,20 +28,35 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
||||
const viewportH = window.innerHeight;
|
||||
const viewportW = window.innerWidth;
|
||||
|
||||
// Default: below and to the right of avatar
|
||||
let top = anchor.bottom + 8;
|
||||
let left = anchor.left;
|
||||
|
||||
// Flip up if not enough space below
|
||||
if (top + 220 > viewportH) top = anchor.top - 228;
|
||||
// Clamp right edge
|
||||
if (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]);
|
||||
|
||||
if (!user) return null;
|
||||
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
|
||||
@@ -44,7 +64,7 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 1000,
|
||||
background: 'white',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||
@@ -56,26 +76,56 @@ export default function UserProfilePopup({ user, anchorEl, onClose }) {
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Avatar user={user} size="xl" />
|
||||
<Avatar user={profileUser} size="xl" />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text-primary)', marginBottom: 2 }}>
|
||||
{user.display_name || user.name}
|
||||
{profileUser.name}
|
||||
</div>
|
||||
{user.role === 'admin' && !user.hide_admin_tag && (
|
||||
{profileUser.role === 'admin' && !profileUser.hide_admin_tag && (
|
||||
<span className="role-badge role-admin" style={{ fontSize: 11 }}>Admin</span>
|
||||
)}
|
||||
</div>
|
||||
{user.about_me && (
|
||||
{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%'
|
||||
paddingTop: 10, width: '100%',
|
||||
}}>
|
||||
{user.about_me}
|
||||
{profileUser.about_me}
|
||||
</p>
|
||||
)}
|
||||
{!isSelf && onDirectMessage && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
--font: 'Google Sans', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; font-family: var(--font); color: var(--text-primary); background: var(--background); }
|
||||
html, body, #root { height: 100%; min-width: 320px; font-family: var(--font); color: var(--text-primary); background: var(--background); }
|
||||
|
||||
button { font-family: var(--font); cursor: pointer; border: none; background: none; }
|
||||
input, textarea { font-family: var(--font); }
|
||||
@@ -226,6 +226,9 @@ a { color: inherit; text-decoration: none; }
|
||||
[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); }
|
||||
@@ -236,6 +239,10 @@ a { color: inherit; text-decoration: none; }
|
||||
[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); }
|
||||
@@ -243,3 +250,92 @@ a { color: inherit; text-decoration: none; }
|
||||
[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); }
|
||||
|
||||
@@ -16,6 +16,7 @@ if ('serviceWorker' in navigator) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Clear badge count when user focuses the app
|
||||
window.addEventListener('focus', () => {
|
||||
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});
|
||||
|
||||
@@ -1,12 +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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import ProfileModal from '../components/ProfileModal.jsx';
|
||||
import UserManagerModal from '../components/UserManagerModal.jsx';
|
||||
import SettingsModal from '../components/SettingsModal.jsx';
|
||||
import NewChatModal from '../components/NewChatModal.jsx';
|
||||
import GlobalBar from '../components/GlobalBar.jsx';
|
||||
import AboutModal from '../components/AboutModal.jsx';
|
||||
import './Chat.css';
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
@@ -99,7 +101,8 @@ export default function Chat() {
|
||||
privateGroups: prev.privateGroups.map(updateGroup),
|
||||
};
|
||||
});
|
||||
// Increment unread count for the group if not currently viewing it
|
||||
// Don't badge: message is from this user, or group is currently open
|
||||
if (msg.user_id === user?.id) return;
|
||||
setUnreadGroups(prev => {
|
||||
if (msg.group_id === activeGroupId) return prev;
|
||||
const next = new Map(prev);
|
||||
@@ -110,14 +113,8 @@ export default function Chat() {
|
||||
|
||||
const handleNotification = (notif) => {
|
||||
if (notif.type === 'private_message') {
|
||||
// Private message unread is already handled by handleNewMsg above
|
||||
// (kept for push notification path when socket is not the source)
|
||||
setUnreadGroups(prev => {
|
||||
if (notif.groupId === activeGroupId) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(notif.groupId, (next.get(notif.groupId) || 0) + 1);
|
||||
return next;
|
||||
});
|
||||
// Badge is already handled by handleNewMsg via message:new socket event.
|
||||
// Nothing to do here for the socket path.
|
||||
} else {
|
||||
setNotifications(prev => [notif, ...prev]);
|
||||
toast(`${notif.fromUser?.display_name || notif.fromUser?.name || 'Someone'} mentioned you`, 'default', 4000);
|
||||
@@ -127,11 +124,51 @@ export default function Chat() {
|
||||
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),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('group:new', handleGroupNew);
|
||||
socket.on('group:deleted', handleGroupDeleted);
|
||||
socket.on('group:updated', handleGroupUpdated);
|
||||
|
||||
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, toast]);
|
||||
}, [socket, toast, activeGroupId, user, isMobile, loadGroups]);
|
||||
|
||||
const selectGroup = (id) => {
|
||||
setActiveGroupId(id);
|
||||
@@ -141,6 +178,13 @@ export default function Chat() {
|
||||
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
|
||||
};
|
||||
|
||||
// Update page title with total unread badge count
|
||||
useEffect(() => {
|
||||
const totalUnread = [...unreadGroups.values()].reduce((a, b) => a + b, 0);
|
||||
const base = document.title.replace(/^\(\d+\)\s*/, '');
|
||||
document.title = totalUnread > 0 ? `(${totalUnread}) ${base}` : base;
|
||||
}, [unreadGroups]);
|
||||
|
||||
const activeGroup = [
|
||||
...(groups.publicGroups || []),
|
||||
...(groups.privateGroups || [])
|
||||
@@ -148,33 +192,42 @@ export default function Chat() {
|
||||
|
||||
return (
|
||||
<div className="chat-layout">
|
||||
{(!isMobile || showSidebar) && (
|
||||
<Sidebar
|
||||
groups={groups}
|
||||
activeGroupId={activeGroupId}
|
||||
onSelectGroup={selectGroup}
|
||||
notifications={notifications}
|
||||
unreadGroups={unreadGroups}
|
||||
onNewChat={() => setModal('newchat')}
|
||||
onProfile={() => setModal('profile')}
|
||||
onUsers={() => setModal('users')}
|
||||
onSettings={() => setModal('settings')}
|
||||
onGroupsUpdated={loadGroups}
|
||||
/>
|
||||
)}
|
||||
{/* Global top bar — spans full width on desktop, visible on mobile sidebar view */}
|
||||
<GlobalBar isMobile={isMobile} showSidebar={showSidebar} />
|
||||
|
||||
{(!isMobile || !showSidebar) && (
|
||||
<ChatWindow
|
||||
group={activeGroup}
|
||||
onBack={isMobile ? () => setShowSidebar(true) : null}
|
||||
onGroupUpdated={loadGroups}
|
||||
/>
|
||||
)}
|
||||
<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')}
|
||||
onGroupsUpdated={loadGroups}
|
||||
isMobile={isMobile}
|
||||
onAbout={() => setModal('about')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!isMobile || !showSidebar) && (
|
||||
<ChatWindow
|
||||
group={activeGroup}
|
||||
onBack={isMobile ? () => { setShowSidebar(true); setActiveGroupId(null); } : null}
|
||||
onGroupUpdated={loadGroups}
|
||||
onDirectMessage={(g) => { loadGroups(); selectGroup(g.id); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||
{modal === 'users' && <UserManagerModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} />}
|
||||
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,17 @@ 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 = {};
|
||||
@@ -45,11 +56,13 @@ export const api = {
|
||||
searchUsers: (q) => req('GET', `/users/search?q=${encodeURIComponent(q)}`),
|
||||
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 }
|
||||
uploadAvatar: (file) => {
|
||||
const form = new FormData(); form.append('avatar', file);
|
||||
|
||||
Reference in New Issue
Block a user