adding chat features, admin features and bug fixes
This commit is contained in:
72
frontend/src/components/chat/ChatMessage.tsx
Normal file
72
frontend/src/components/chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import type { NextcloudMessage } from '../../types/nextcloud.types';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: NextcloudMessage;
|
||||
isOwnMessage: boolean;
|
||||
}
|
||||
|
||||
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
|
||||
const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
if (message.systemMessage) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
{message.message} - {time}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: isOwnMessage ? 'flex-end' : 'flex-start',
|
||||
my: 0.5,
|
||||
px: 1,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
maxWidth: '80%',
|
||||
bgcolor: isOwnMessage ? 'primary.main' : (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200',
|
||||
color: isOwnMessage ? 'primary.contrastText' : 'text.primary',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{!isOwnMessage && (
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }}>
|
||||
{message.actorDisplayName}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{message.message}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
textAlign: 'right',
|
||||
mt: 0.25,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{time}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
119
frontend/src/components/chat/ChatMessageView.tsx
Normal file
119
frontend/src/components/chat/ChatMessageView.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { nextcloudApi } from '../../services/nextcloud';
|
||||
import { useChat } from '../../contexts/ChatContext';
|
||||
import { useLayout } from '../../contexts/LayoutContext';
|
||||
import ChatMessage from './ChatMessage';
|
||||
|
||||
const ChatMessageView: React.FC = () => {
|
||||
const { selectedRoomToken, selectRoom, rooms, loginName } = useChat();
|
||||
const { chatPanelOpen } = useLayout();
|
||||
const queryClient = useQueryClient();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const room = rooms.find((r) => r.token === selectedRoomToken);
|
||||
|
||||
const { data: messages } = useQuery({
|
||||
queryKey: ['nextcloud', 'messages', selectedRoomToken],
|
||||
queryFn: () => nextcloudApi.getMessages(selectedRoomToken!),
|
||||
enabled: !!selectedRoomToken && chatPanelOpen,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (message: string) => nextcloudApi.sendMessage(selectedRoomToken!, message),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'messages', selectedRoomToken] });
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRoomToken && chatPanelOpen) {
|
||||
nextcloudApi.markAsRead(selectedRoomToken).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [selectedRoomToken, chatPanelOpen, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || !selectedRoomToken) return;
|
||||
sendMutation.mutate(trimmed);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedRoomToken) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Raum auswählen
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
||||
<Box sx={{ px: 1.5, py: 1, borderBottom: 1, borderColor: 'divider', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton size="small" onClick={() => selectRoom(null)}>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography variant="subtitle2" noWrap>
|
||||
{room?.displayName ?? selectedRoomToken}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
|
||||
{messages?.map((msg) => (
|
||||
<ChatMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider', display: 'flex', gap: 0.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Nachricht..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
multiline
|
||||
maxRows={3}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || sendMutation.isPending}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageView;
|
||||
93
frontend/src/components/chat/ChatPanel.tsx
Normal file
93
frontend/src/components/chat/ChatPanel.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ChatIcon from '@mui/icons-material/Chat';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLayout } from '../../contexts/LayoutContext';
|
||||
import { ChatProvider, useChat } from '../../contexts/ChatContext';
|
||||
import ChatRoomList from './ChatRoomList';
|
||||
import ChatMessageView from './ChatMessageView';
|
||||
|
||||
const ChatPanelInner: React.FC = () => {
|
||||
const { chatPanelOpen, setChatPanelOpen } = useLayout();
|
||||
const { selectedRoomToken, connected } = useChat();
|
||||
|
||||
if (!chatPanelOpen) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
width: 60,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
pt: 1,
|
||||
flexShrink: 0,
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={() => setChatPanelOpen(true)}>
|
||||
<ChatIcon />
|
||||
</IconButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
width: 360,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
transition: 'width 0.2s ease',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Chat
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setChatPanelOpen(false)}>
|
||||
<ChatIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{!connected ? (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : selectedRoomToken ? (
|
||||
<ChatMessageView />
|
||||
) : (
|
||||
<ChatRoomList />
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatPanel: React.FC = () => {
|
||||
return (
|
||||
<ChatProvider>
|
||||
<ChatPanelInner />
|
||||
</ChatProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPanel;
|
||||
48
frontend/src/components/chat/ChatRoomList.tsx
Normal file
48
frontend/src/components/chat/ChatRoomList.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import List from '@mui/material/List';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useChat } from '../../contexts/ChatContext';
|
||||
|
||||
const ChatRoomList: React.FC = () => {
|
||||
const { rooms, selectedRoomToken, selectRoom } = useChat();
|
||||
|
||||
return (
|
||||
<Box sx={{ overflow: 'auto', flex: 1 }}>
|
||||
<List disablePadding>
|
||||
{rooms.map((room) => (
|
||||
<ListItemButton
|
||||
key={room.token}
|
||||
selected={room.token === selectedRoomToken}
|
||||
onClick={() => selectRoom(room.token)}
|
||||
sx={{ py: 1, px: 1.5 }}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle2" noWrap sx={{ flex: 1 }}>
|
||||
{room.displayName}
|
||||
</Typography>
|
||||
{room.unreadMessages > 0 && (
|
||||
<Badge
|
||||
badgeContent={room.unreadMessages}
|
||||
color="primary"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{room.lastMessage && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{room.lastMessage.author}: {room.lastMessage.text}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatRoomList;
|
||||
Reference in New Issue
Block a user