version 0.0.24
@@ -2,13 +2,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="icon" type="image/png" href="/icons/jama.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1a73e8" />
|
||||
<meta name="description" content="TeamChat - Modern team messaging" />
|
||||
<meta name="description" content="jama - just another messaging app" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>TeamChat</title>
|
||||
<title>jama</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1021 B After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 144 KiB |
BIN
frontend/public/icons/jama.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
frontend/public/icons/logo-64.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TeamChat",
|
||||
"short_name": "TeamChat",
|
||||
"name": "jama",
|
||||
"short_name": "jama",
|
||||
"description": "Modern team messaging application",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'teamchat-v2';
|
||||
const CACHE_NAME = 'jama-v1';
|
||||
const STATIC_ASSETS = ['/'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
@@ -47,7 +47,7 @@ self.addEventListener('push', (event) => {
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { url: data.url || '/' },
|
||||
tag: 'teamchat-message', // replaces previous notification instead of stacking
|
||||
tag: 'jama-message', // replaces previous notification instead of stacking
|
||||
renotify: true, // still vibrate/sound even if replacing
|
||||
})
|
||||
);
|
||||
|
||||
@@ -29,8 +29,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||
}).catch(() => {});
|
||||
const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {});
|
||||
window.addEventListener('teamchat:settings-changed', handler);
|
||||
return () => window.removeEventListener('teamchat:settings-changed', handler);
|
||||
window.addEventListener('jama:settings-changed', handler);
|
||||
return () => window.removeEventListener('jama:settings-changed', handler);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
@@ -172,15 +172,15 @@ export default function ChatWindow({ group, onBack, onGroupUpdated }) {
|
||||
) : null}
|
||||
</div>
|
||||
<span className="chat-header-sub">
|
||||
{group.type === 'public' ? 'Public channel' : 'Private group'}
|
||||
{group.type === 'public' ? 'Public group' : 'Private group'}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setShowInfo(true)} title="Group info">
|
||||
{iconGroupInfo ? (
|
||||
<img src={iconGroupInfo} alt="Group info" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="24" height="24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />
|
||||
<svg 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" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -71,6 +71,15 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleRemove = async (member) => {
|
||||
if (!confirm(`Remove ${member.display_name || member.name} from this group?`)) return;
|
||||
try {
|
||||
await api.removeMember(group.id, member.id);
|
||||
toast(`${member.display_name || 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;
|
||||
try {
|
||||
@@ -125,10 +134,28 @@ export default function GroupInfoModal({ group, onClose, onUpdated }) {
|
||||
</div>
|
||||
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{members.map(m => (
|
||||
<div key={m.id} className="flex items-center gap-2" style={{ gap: 10, padding: '6px 0' }}>
|
||||
<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>
|
||||
{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)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-tertiary)'}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function Message({ message: msg, prevMessage, currentUser, onRepl
|
||||
|
||||
const canDelete = (
|
||||
msg.user_id === currentUser.id ||
|
||||
(currentUser.role === 'admin' && msg.group_type !== 'private') ||
|
||||
currentUser.role === 'admin' ||
|
||||
(msg.group_owner_id === currentUser.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -2,51 +2,6 @@ import { useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
|
||||
function IconUploadRow({ label, settingKey, currentUrl, onUploaded, defaultSvg }) {
|
||||
const toast = useToast();
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 1024 * 1024) return toast(`${label} icon must be less than 1MB`, 'error');
|
||||
try {
|
||||
let result;
|
||||
if (settingKey === 'icon_newchat') result = await api.uploadIconNewChat(file);
|
||||
else result = await api.uploadIconGroupInfo(file);
|
||||
onUploaded(settingKey, result.iconUrl);
|
||||
toast(`${label} icon updated`, 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 10, background: 'var(--background)',
|
||||
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||
}}>
|
||||
{currentUrl ? (
|
||||
<img src={currentUrl} alt={label} style={{ width: 32, height: 32, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<span style={{ opacity: 0.35 }}>{defaultSvg}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{label}</div>
|
||||
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||
Upload PNG
|
||||
<input type="file" accept="image/png,image/svg+xml,image/*" style={{ display: 'none' }} onChange={handleUpload} />
|
||||
</label>
|
||||
{currentUrl && (
|
||||
<span style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-tertiary)' }}>Custom icon active</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState({});
|
||||
@@ -58,11 +13,11 @@ export default function SettingsModal({ onClose }) {
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setSettings(settings);
|
||||
setAppName(settings.app_name || 'TeamChat');
|
||||
setAppName(settings.app_name || 'jama');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const notifySidebarRefresh = () => window.dispatchEvent(new Event('teamchat:settings-changed'));
|
||||
const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!appName.trim()) return;
|
||||
@@ -93,18 +48,13 @@ export default function SettingsModal({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconUploaded = (key, url) => {
|
||||
setSettings(prev => ({ ...prev, [key]: url }));
|
||||
notifySidebarRefresh();
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.resetSettings();
|
||||
const { settings: fresh } = await api.getSettings();
|
||||
setSettings(fresh);
|
||||
setAppName(fresh.app_name || 'TeamChat');
|
||||
setAppName(fresh.app_name || 'jama');
|
||||
toast('Settings reset to defaults', 'success');
|
||||
notifySidebarRefresh();
|
||||
setShowResetConfirm(false);
|
||||
@@ -115,18 +65,6 @@ export default function SettingsModal({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
const newChatSvg = (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
const groupInfoSvg = (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 460 }}>
|
||||
@@ -146,23 +84,20 @@ export default function SettingsModal({ onClose }) {
|
||||
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', flexShrink: 0
|
||||
}}>
|
||||
{settings.logo_url ? (
|
||||
<img src={settings.logo_url} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<svg viewBox="0 0 48 48" fill="none" style={{ width: 48, height: 48 }}>
|
||||
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
<img
|
||||
src={settings.logo_url || '/icons/jama.png'}
|
||||
alt="logo"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>
|
||||
Upload Logo
|
||||
<input type="file" accept="image/*" style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||
</label>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>Square format, max 1MB. Used in sidebar, login page and browser tab.</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>
|
||||
Square format, max 1MB. Used in sidebar, login page and browser tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,58 +113,37 @@ export default function SettingsModal({ onClose }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Icons */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div className="settings-section-label">Interface Icons</div>
|
||||
<IconUploadRow
|
||||
label="New Chat Button"
|
||||
settingKey="icon_newchat"
|
||||
currentUrl={settings.icon_newchat}
|
||||
onUploaded={handleIconUploaded}
|
||||
defaultSvg={newChatSvg}
|
||||
/>
|
||||
<IconUploadRow
|
||||
label="Group Info Button"
|
||||
settingKey="icon_groupinfo"
|
||||
currentUrl={settings.icon_groupinfo}
|
||||
onUploaded={handleIconUploaded}
|
||||
defaultSvg={groupInfoSvg}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset + Version */}
|
||||
<div style={{ marginBottom: settings.pw_reset_active === 'true' ? 16 : 0 }}>
|
||||
<div className="settings-section-label">Reset</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
{!showResetConfirm ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>
|
||||
Reset All to Defaults
|
||||
</button>
|
||||
) : (
|
||||
<div style={{
|
||||
background: '#fce8e6', border: '1px solid #f5c6c2',
|
||||
borderRadius: 'var(--radius)', padding: '12px 14px'
|
||||
}}>
|
||||
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
|
||||
This will reset the app name, logo, and all custom icons to their install defaults. This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
|
||||
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
{!showResetConfirm ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(true)}>
|
||||
Reset All to Defaults
|
||||
</button>
|
||||
) : (
|
||||
<div style={{
|
||||
background: '#fce8e6', border: '1px solid #f5c6c2',
|
||||
borderRadius: 'var(--radius)', padding: '12px 14px'
|
||||
}}>
|
||||
<p style={{ fontSize: 13, color: 'var(--error)', marginBottom: 12 }}>
|
||||
This will reset the app name and logo to their install defaults. This cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={handleReset} disabled={resetting}>
|
||||
{resetting ? 'Resetting...' : 'Yes, Reset Everything'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowResetConfirm(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{settings.app_version && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||||
v{settings.app_version}
|
||||
</span>
|
||||
)}
|
||||
</div>{/* end flex row */}
|
||||
</div>{/* end Reset section */}
|
||||
)}
|
||||
{settings.app_version && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>
|
||||
v{settings.app_version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.pw_reset_active === 'true' && (
|
||||
<div className="warning-banner">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
background: #e53935;
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
@@ -188,14 +188,25 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo-default {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo-default svg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: block;
|
||||
/* Unread message indicator */
|
||||
.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;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
padding: 0 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,19 @@ import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import './Sidebar.css';
|
||||
|
||||
function useTheme() {
|
||||
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
|
||||
}, [dark]);
|
||||
|
||||
return [dark, setDark];
|
||||
}
|
||||
|
||||
function useAppSettings() {
|
||||
const [settings, setSettings] = useState({ app_name: 'TeamChat', logo_url: '' });
|
||||
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
|
||||
|
||||
const fetchSettings = () => {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
@@ -16,20 +27,20 @@ function useAppSettings() {
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
// Re-fetch when settings are saved from the SettingsModal
|
||||
window.addEventListener('teamchat:settings-changed', fetchSettings);
|
||||
return () => window.removeEventListener('teamchat:settings-changed', fetchSettings);
|
||||
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 || 'TeamChat';
|
||||
const name = settings.app_name || 'jama';
|
||||
|
||||
// Update <title>
|
||||
document.title = name;
|
||||
|
||||
// Update favicon
|
||||
const logoUrl = settings.logo_url;
|
||||
const faviconUrl = logoUrl || '/logo.svg';
|
||||
const faviconUrl = logoUrl || '/icons/jama.png';
|
||||
let link = document.querySelector("link[rel~='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
@@ -42,15 +53,16 @@ function useAppSettings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Set(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) {
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { connected } = useSocket();
|
||||
const toast = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const settings = useAppSettings();
|
||||
const [dark, setDark] = useTheme();
|
||||
|
||||
const appName = settings.app_name || 'TeamChat';
|
||||
const appName = settings.app_name || 'jama';
|
||||
const logoUrl = settings.logo_url;
|
||||
|
||||
const allGroups = [
|
||||
@@ -73,7 +85,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
|
||||
const GroupItem = ({ group }) => {
|
||||
const notifs = getNotifCount(group.id);
|
||||
const hasUnread = unreadGroups.has(group.id);
|
||||
const unreadCount = unreadGroups.get(group.id) || 0;
|
||||
const hasUnread = unreadCount > 0;
|
||||
const isActive = group.id === activeGroupId;
|
||||
|
||||
return (
|
||||
@@ -93,7 +106,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
{group.last_message || (group.is_readonly ? '📢 Read-only' : 'No messages yet')}
|
||||
</span>
|
||||
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
|
||||
{hasUnread && notifs === 0 && <span className="unread-dot shrink-0" />}
|
||||
{hasUnread && notifs === 0 && <span className="badge badge-unread shrink-0">{unreadCount}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,14 +121,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={appName} className="sidebar-logo" />
|
||||
) : (
|
||||
<div className="sidebar-logo-default">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<img src="/icons/jama.png" alt="jama" className="sidebar-logo" />
|
||||
)}
|
||||
<h2 className="sidebar-title truncate">{appName}</h2>
|
||||
{!connected && <span className="offline-dot" title="Offline" />}
|
||||
@@ -124,9 +130,9 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
{settings.icon_newchat ? (
|
||||
<img src={settings.icon_newchat} alt="New Chat" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -145,7 +151,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
<div className="groups-list">
|
||||
{publicFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">CHANNELS</div>
|
||||
<div className="section-label">PUBLIC MESSAGES</div>
|
||||
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
@@ -166,16 +172,40 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
|
||||
{/* User footer */}
|
||||
<div className="sidebar-footer">
|
||||
<button className="user-footer-btn" onClick={() => setShowMenu(!showMenu)}>
|
||||
<Avatar user={user} size="sm" />
|
||||
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
|
||||
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
|
||||
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
|
||||
<Avatar user={user} size="sm" />
|
||||
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
|
||||
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
|
||||
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDark(d => !d)}
|
||||
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
style={{ flexShrink: 0, padding: 8 }}
|
||||
>
|
||||
{dark ? (
|
||||
/* 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"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
) : (
|
||||
/* 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>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMenu && (
|
||||
<div className="footer-menu" onClick={() => setShowMenu(false)}>
|
||||
|
||||
@@ -46,6 +46,16 @@ export function AuthProvider({ children }) {
|
||||
setMustChangePassword(false);
|
||||
};
|
||||
|
||||
// Listen for session displacement (another device logged in)
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setUser(null);
|
||||
setMustChangePassword(false);
|
||||
};
|
||||
window.addEventListener('jama:session-displaced', handler);
|
||||
return () => window.removeEventListener('jama:session-displaced', handler);
|
||||
}, []);
|
||||
|
||||
const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates }));
|
||||
|
||||
return (
|
||||
|
||||
@@ -197,3 +197,49 @@ a { color: inherit; text-decoration: none; }
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Dark mode ─────────────────────────────────────────── */
|
||||
[data-theme="dark"] {
|
||||
--primary: #6ab0f5;
|
||||
--primary-dark: #4d9de0;
|
||||
--primary-light: #1a2d4a;
|
||||
--surface: #1e1e2e;
|
||||
--surface-variant: #252535;
|
||||
--background: #13131f;
|
||||
--border: #2e2e45;
|
||||
--text-primary: #e2e2f0;
|
||||
--text-secondary: #9898b8;
|
||||
--text-tertiary: #6060808;
|
||||
--bubble-out: #4d8fd4;
|
||||
--bubble-in: #252535;
|
||||
}
|
||||
[data-theme="dark"] body,
|
||||
[data-theme="dark"] html,
|
||||
[data-theme="dark"] #root { background: var(--background); }
|
||||
[data-theme="dark"] .modal { background: var(--surface); }
|
||||
[data-theme="dark"] .footer-menu { background: var(--surface); }
|
||||
[data-theme="dark"] .sidebar { background: var(--surface); }
|
||||
[data-theme="dark"] .chat-window { background: var(--background); }
|
||||
[data-theme="dark"] .chat-header { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .messages-container { background: var(--background); }
|
||||
[data-theme="dark"] .input { background: var(--surface-variant); border-color: var(--border); color: var(--text-primary); }
|
||||
[data-theme="dark"] .card { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .message-input-area { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .message-input-wrap { background: var(--surface-variant); border-color: var(--border); }
|
||||
[data-theme="dark"] .btn-secondary { border-color: var(--border); color: var(--primary); }
|
||||
[data-theme="dark"] .btn-secondary:hover { background: var(--primary-light); }
|
||||
[data-theme="dark"] .search-input { background: var(--surface-variant); color: var(--text-primary); }
|
||||
[data-theme="dark"] .group-item:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .group-item.active { background: var(--primary-light); }
|
||||
[data-theme="dark"] .user-footer-btn:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .footer-menu-item:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .footer-menu-item.danger:hover { background: #3a1a1a; }
|
||||
[data-theme="dark"] .btn-icon { color: var(--text-primary); }
|
||||
[data-theme="dark"] .btn-icon:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .msg-actions { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .reaction-btn:hover { background: var(--surface-variant); }
|
||||
[data-theme="dark"] .emoji-picker-wrap { background: var(--surface); border-color: var(--border); }
|
||||
[data-theme="dark"] .reply-preview { background: var(--surface-variant); border-color: var(--primary); }
|
||||
[data-theme="dark"] .load-more-btn { background: var(--surface-variant); color: var(--text-secondary); }
|
||||
[data-theme="dark"] .readonly-bar { background: var(--surface); border-color: var(--border); color: var(--text-secondary); }
|
||||
[data-theme="dark"] .warning-banner { background: #2a1f00; border-color: #6a4a00; color: #ffb74d; }
|
||||
|
||||
@@ -3,6 +3,10 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
// Apply saved theme immediately to avoid flash of wrong theme
|
||||
const savedTheme = localStorage.getItem('jama-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
// Register service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Chat() {
|
||||
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
|
||||
const [activeGroupId, setActiveGroupId] = useState(null);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadGroups, setUnreadGroups] = useState(new Set());
|
||||
const [unreadGroups, setUnreadGroups] = useState(new Map());
|
||||
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat'
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
@@ -89,6 +89,7 @@ export default function Chat() {
|
||||
if (!socket) return;
|
||||
|
||||
const handleNewMsg = (msg) => {
|
||||
// Update group preview text
|
||||
setGroups(prev => {
|
||||
const updateGroup = (g) => g.id === msg.group_id
|
||||
? { ...g, last_message: msg.content || (msg.image_url ? '📷 Image' : ''), last_message_at: msg.created_at }
|
||||
@@ -98,15 +99,23 @@ export default function Chat() {
|
||||
privateGroups: prev.privateGroups.map(updateGroup),
|
||||
};
|
||||
});
|
||||
// Increment unread count for the group if not currently viewing it
|
||||
setUnreadGroups(prev => {
|
||||
if (msg.group_id === activeGroupId) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(msg.group_id, (next.get(msg.group_id) || 0) + 1);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleNotification = (notif) => {
|
||||
if (notif.type === 'private_message') {
|
||||
// Show unread dot on private group in sidebar (if not currently viewing it)
|
||||
// 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 Set(prev);
|
||||
next.add(notif.groupId);
|
||||
const next = new Map(prev);
|
||||
next.set(notif.groupId, (next.get(notif.groupId) || 0) + 1);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
@@ -127,9 +136,9 @@ export default function Chat() {
|
||||
const selectGroup = (id) => {
|
||||
setActiveGroupId(id);
|
||||
if (isMobile) setShowSidebar(false);
|
||||
// Clear notifications for this group
|
||||
// Clear notifications and unread count for this group
|
||||
setNotifications(prev => prev.filter(n => n.groupId !== id));
|
||||
setUnreadGroups(prev => { const next = new Set(prev); next.delete(id); return next; });
|
||||
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
|
||||
};
|
||||
|
||||
const activeGroup = [
|
||||
|
||||
@@ -29,12 +29,6 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.default-logo svg {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-logo h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const appName = settings.app_name || 'TeamChat';
|
||||
const appName = settings.app_name || 'jama';
|
||||
const logoUrl = settings.logo_url;
|
||||
|
||||
return (
|
||||
@@ -76,14 +76,7 @@ export default function Login() {
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={appName} className="logo-img" />
|
||||
) : (
|
||||
<div className="default-logo">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<img src="/icons/jama.png" alt="jama" className="logo-img" />
|
||||
)}
|
||||
<h1>{appName}</h1>
|
||||
<p>Sign in to continue</p>
|
||||
|
||||
@@ -20,7 +20,15 @@ async function req(method, path, body, opts = {}) {
|
||||
|
||||
const res = await fetch(BASE + path, fetchOpts);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Request failed');
|
||||
if (!res.ok) {
|
||||
// Session displaced by a new login elsewhere — force logout
|
||||
if (res.status === 401 && data.error?.includes('Session expired')) {
|
||||
localStorage.removeItem('tc_token');
|
||||
sessionStorage.removeItem('tc_token');
|
||||
window.dispatchEvent(new CustomEvent('jama:session-displaced'));
|
||||
}
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -54,6 +62,7 @@ export const api = {
|
||||
renameGroup: (id, name) => req('PATCH', `/groups/${id}/rename`, { name }),
|
||||
getMembers: (id) => req('GET', `/groups/${id}/members`),
|
||||
addMember: (groupId, userId) => req('POST', `/groups/${groupId}/members`, { userId }),
|
||||
removeMember: (groupId, userId) => req('DELETE', `/groups/${groupId}/members/${userId}`),
|
||||
leaveGroup: (id) => req('DELETE', `/groups/${id}/leave`),
|
||||
takeOwnership: (id) => req('POST', `/groups/${id}/take-ownership`),
|
||||
deleteGroup: (id) => req('DELETE', `/groups/${id}`),
|
||||
|
||||