Initial Commit
This commit is contained in:
222
frontend/src/components/Sidebar.jsx
Normal file
222
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import './Sidebar.css';
|
||||
|
||||
function useAppSettings() {
|
||||
const [settings, setSettings] = useState({ app_name: 'TeamChat', logo_url: '' });
|
||||
|
||||
const fetchSettings = () => {
|
||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
// Re-fetch when settings are saved from the SettingsModal
|
||||
window.addEventListener('teamchat:settings-changed', fetchSettings);
|
||||
return () => window.removeEventListener('teamchat:settings-changed', fetchSettings);
|
||||
}, []);
|
||||
|
||||
// Update page title and favicon whenever settings change
|
||||
useEffect(() => {
|
||||
const name = settings.app_name || 'TeamChat';
|
||||
|
||||
// Update <title>
|
||||
document.title = name;
|
||||
|
||||
// Update favicon
|
||||
const logoUrl = settings.logo_url;
|
||||
const faviconUrl = logoUrl || '/logo.svg';
|
||||
let link = document.querySelector("link[rel~='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = faviconUrl;
|
||||
}, [settings]);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Set(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onGroupsUpdated }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { connected } = useSocket();
|
||||
const toast = useToast();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const settings = useAppSettings();
|
||||
|
||||
const appName = settings.app_name || 'TeamChat';
|
||||
const logoUrl = settings.logo_url;
|
||||
|
||||
const allGroups = [
|
||||
...(groups.publicGroups || []),
|
||||
...(groups.privateGroups || [])
|
||||
];
|
||||
|
||||
const filtered = search
|
||||
? allGroups.filter(g => g.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: allGroups;
|
||||
|
||||
const publicFiltered = filtered.filter(g => g.type === 'public');
|
||||
const privateFiltered = filtered.filter(g => g.type === 'private');
|
||||
|
||||
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const GroupItem = ({ group }) => {
|
||||
const notifs = getNotifCount(group.id);
|
||||
const hasUnread = unreadGroups.has(group.id);
|
||||
const isActive = group.id === activeGroupId;
|
||||
|
||||
return (
|
||||
<div className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`} onClick={() => onSelectGroup(group.id)}>
|
||||
<div className="group-icon" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}>
|
||||
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="group-info flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>{group.name}</span>
|
||||
{group.last_message_at && (
|
||||
<span className="group-time">{formatTime(group.last_message_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="group-last-msg truncate">
|
||||
{group.last_message || (group.is_readonly ? '📢 Read-only' : 'No messages yet')}
|
||||
</span>
|
||||
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
|
||||
{hasUnread && notifs === 0 && <span className="unread-dot shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
{/* Header with live app name and logo */}
|
||||
<div className="sidebar-header">
|
||||
<div className="flex items-center gap-2 flex-1" style={{ minWidth: 0 }}>
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={appName} className="sidebar-logo" />
|
||||
) : (
|
||||
<div className="sidebar-logo-default">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="24" cy="24" r="24" fill="#1a73e8"/>
|
||||
<path d="M12 16h24v2H12zM12 22h18v2H12zM12 28h20v2H12z" fill="white"/>
|
||||
<circle cx="36" cy="32" r="8" fill="#34a853"/>
|
||||
<path d="M33 32l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="sidebar-title truncate">{appName}</h2>
|
||||
{!connected && <span className="offline-dot" title="Offline" />}
|
||||
</div>
|
||||
<button className="btn-icon" onClick={onNewChat} title="New Chat">
|
||||
{settings.icon_newchat ? (
|
||||
<img src={settings.icon_newchat} alt="New Chat" style={{ width: 20, height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="30" height="30">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="sidebar-search">
|
||||
<div className="search-wrap">
|
||||
<svg className="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input className="search-input" placeholder="Search chats..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups list */}
|
||||
<div className="groups-list">
|
||||
{publicFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">CHANNELS</div>
|
||||
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{privateFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">DIRECT MESSAGES</div>
|
||||
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-tertiary)', fontSize: 14 }}>
|
||||
No chats found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="sidebar-footer">
|
||||
<button className="user-footer-btn" onClick={() => setShowMenu(!showMenu)}>
|
||||
<Avatar user={user} size="sm" />
|
||||
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
|
||||
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
|
||||
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="footer-menu" onClick={() => setShowMenu(false)}>
|
||||
<button className="footer-menu-item" onClick={onProfile}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Profile
|
||||
</button>
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<button className="footer-menu-item" onClick={onUsers}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
User Manager
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={onOpenSettings}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>
|
||||
Settings
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||
<button className="footer-menu-item danger" onClick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
if (diff < 86400000 && date.getDate() === now.getDate()) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
if (diff < 604800000) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
Reference in New Issue
Block a user