DGSoft cfd7068af5 feat: Add blinking envelope icons for unread messages
- 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
2025-12-12 11:26:36 +01:00

211 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;