mirror of
https://github.com/OHV-IT/collabrix.git
synced 2025-12-15 16:48:36 +01:00
- Implement unread message indicators with Material-UI icons - Add BlinkingEnvelope component with theme-compatible colors - Create UnreadMessagesContext for managing unread states - Integrate WebSocket message handling for real-time notifications - Icons only appear for inactive channels/DMs, disappear when opened - Add test functionality (double-click to mark as unread) - Fix WebSocket URL handling for production deployment - Unify WebSocket architecture using presence connection for all messages
211 lines
7.5 KiB
TypeScript
211 lines
7.5 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||
import { useTheme } from '../../contexts/ThemeContext';
|
||
import { useAuth } from '../../contexts/AuthContext';
|
||
import { departmentsAPI, getApiUrl } from '../../services/api';
|
||
import type { Department } from '../../types';
|
||
import { isAdmin } from '../../types';
|
||
import ToastContainer from '../ToastContainer';
|
||
|
||
const Layout: React.FC = () => {
|
||
const { user, logout } = useAuth();
|
||
const { theme, toggleTheme } = useTheme();
|
||
const location = useLocation();
|
||
const [hasSnippetAccess, setHasSnippetAccess] = useState(false);
|
||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||
|
||
const getInitials = () => {
|
||
if (!user) return '?';
|
||
if (user.full_name) {
|
||
const parts = user.full_name.trim().split(' ');
|
||
if (parts.length >= 2) {
|
||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||
}
|
||
return parts[0][0].toUpperCase();
|
||
}
|
||
return user.username.charAt(0).toUpperCase();
|
||
};
|
||
|
||
// Close menu when clicking outside
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
const target = event.target as HTMLElement;
|
||
if (userMenuOpen && !target.closest('.user-menu-container')) {
|
||
setUserMenuOpen(false);
|
||
}
|
||
};
|
||
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
}, [userMenuOpen]);
|
||
|
||
useEffect(() => {
|
||
let isMounted = true;
|
||
|
||
const determineSnippetAccess = async () => {
|
||
if (!user) {
|
||
if (isMounted) {
|
||
setHasSnippetAccess(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isAdmin(user)) {
|
||
if (isMounted) {
|
||
setHasSnippetAccess(true);
|
||
}
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const departments: Department[] = await departmentsAPI.getMy();
|
||
if (!isMounted) {
|
||
return;
|
||
}
|
||
const enabled = departments.some((dept) => dept.snippets_enabled);
|
||
setHasSnippetAccess(enabled);
|
||
} catch (error) {
|
||
console.error('Failed to determine snippet permissions:', error);
|
||
if (isMounted) {
|
||
setHasSnippetAccess(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
determineSnippetAccess();
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
};
|
||
}, [user?.id, isAdmin(user)]);
|
||
|
||
return (
|
||
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
|
||
{/* Header */}
|
||
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-3 relative">
|
||
<div className="flex items-center">
|
||
<div className="flex items-center space-x-16">
|
||
<div className="flex items-center space-x-2">
|
||
<h1 className="text-base font-bold text-gray-900 dark:text-white">
|
||
Collabrix
|
||
</h1>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400 mt-1 font-inter">
|
||
– Struktur für Teams
|
||
</span>
|
||
</div>
|
||
<nav className="flex space-x-1.5">
|
||
<Link
|
||
to="/"
|
||
className={`px-3 py-1.5 text-sm rounded ${
|
||
location.pathname === '/'
|
||
? 'bg-blue-500 text-white'
|
||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||
}`}
|
||
>
|
||
Chat
|
||
</Link>
|
||
{(isAdmin(user) || hasSnippetAccess) && (
|
||
<Link
|
||
to="/snippets"
|
||
className={`px-3 py-1.5 text-sm rounded ${
|
||
location.pathname === '/snippets'
|
||
? 'bg-blue-500 text-white'
|
||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||
}`}
|
||
>
|
||
Snippets
|
||
</Link>
|
||
)}
|
||
<Link
|
||
to="/kanban"
|
||
className={`px-3 py-1.5 text-sm rounded ${
|
||
location.pathname.startsWith('/kanban')
|
||
? 'bg-blue-500 text-white'
|
||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||
}`}
|
||
>
|
||
Kanban
|
||
</Link>
|
||
</nav>
|
||
</div>
|
||
|
||
<div className="absolute right-3 top-2 flex items-center space-x-3">
|
||
<ToastContainer />
|
||
<div className="flex items-center space-x-2.5 relative user-menu-container">
|
||
<button
|
||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||
className="flex items-center space-x-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||
>
|
||
{user?.profile_picture ? (
|
||
<img
|
||
src={getApiUrl(user.profile_picture)}
|
||
alt={user.username}
|
||
className="w-8 h-8 rounded-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-bold">
|
||
{getInitials()}
|
||
</div>
|
||
)}
|
||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||
{user?.full_name || user?.username}
|
||
</span>
|
||
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
|
||
{userMenuOpen && (
|
||
<div className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
|
||
<Link
|
||
to="/profile"
|
||
onClick={() => setUserMenuOpen(false)}
|
||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||
>
|
||
Profil
|
||
</Link>
|
||
{isAdmin(user) && (
|
||
<Link
|
||
to="/admin"
|
||
onClick={() => setUserMenuOpen(false)}
|
||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||
>
|
||
Administration
|
||
</Link>
|
||
)}
|
||
<button
|
||
onClick={() => {
|
||
toggleTheme();
|
||
setUserMenuOpen(false);
|
||
}}
|
||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||
>
|
||
{theme === 'light' ? 'Dark Mode' : 'Light Mode'}
|
||
</button>
|
||
<hr className="my-1 border-gray-200 dark:border-gray-700" />
|
||
<button
|
||
onClick={() => {
|
||
logout();
|
||
setUserMenuOpen(false);
|
||
}}
|
||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||
>
|
||
Logout
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Main Content */}
|
||
<main className="flex-1 min-h-0 overflow-y-auto">
|
||
<Outlet />
|
||
</main>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Layout;
|