add now features
This commit is contained in:
281
frontend/src/components/dashboard/NextcloudTalkWidget.tsx
Normal file
281
frontend/src/components/dashboard/NextcloudTalkWidget.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
Divider,
|
||||
Skeleton,
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import { Forum, Cloud, LinkOff } from '@mui/icons-material';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { nextcloudApi } from '../../services/nextcloud';
|
||||
import type { NextcloudConversation } from '../../types/nextcloud.types';
|
||||
|
||||
const POLL_INTERVAL = 2000;
|
||||
const POLL_TIMEOUT = 5 * 60 * 1000;
|
||||
|
||||
const ConversationRow: React.FC<{ conversation: NextcloudConversation; showDivider: boolean }> = ({
|
||||
conversation,
|
||||
showDivider,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
window.open(conversation.url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const relativeTime = conversation.lastMessage
|
||||
? formatDistanceToNow(new Date(conversation.lastMessage.timestamp * 1000), {
|
||||
addSuffix: true,
|
||||
locale: de,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
py: 1.5,
|
||||
px: 1,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
transition: 'background-color 0.15s ease',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2" noWrap>
|
||||
{conversation.displayName}
|
||||
</Typography>
|
||||
{conversation.unreadMessages > 0 && (
|
||||
<Chip
|
||||
label={conversation.unreadMessages}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ height: 20, fontSize: '0.7rem', minWidth: 24 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{conversation.lastMessage && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{ mt: 0.25 }}
|
||||
>
|
||||
{conversation.lastMessage.author}: {conversation.lastMessage.text}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{relativeTime && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
|
||||
>
|
||||
{relativeTime}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{showDivider && <Divider />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const NextcloudTalkWidget: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const popupRef = useRef<Window | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['nextcloud-talk'],
|
||||
queryFn: () => nextcloudApi.getConversations(),
|
||||
refetchInterval: 30000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const connected = data?.connected ?? false;
|
||||
const conversations = data?.conversations?.slice(0, 5) ?? [];
|
||||
const totalUnread = data?.totalUnread ?? 0;
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
popupRef.current = null;
|
||||
setIsConnecting(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleConnect = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const { loginUrl, pollToken, pollEndpoint } = await nextcloudApi.connect();
|
||||
|
||||
const popup = window.open(loginUrl, '_blank', 'width=600,height=700');
|
||||
popupRef.current = popup;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
if (Date.now() - startTime > POLL_TIMEOUT) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (popup && popup.closed) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await nextcloudApi.poll(pollToken, pollEndpoint);
|
||||
if (result.completed) {
|
||||
stopPolling();
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] });
|
||||
}
|
||||
} catch {
|
||||
// Polling error — keep trying until timeout
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
} catch {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
await nextcloudApi.disconnect();
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] });
|
||||
} catch {
|
||||
// Disconnect failed silently
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Forum color="primary" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Nextcloud Talk
|
||||
</Typography>
|
||||
{connected && totalUnread > 0 && (
|
||||
<Chip
|
||||
label={`${totalUnread} ungelesen`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{connected && (
|
||||
<Tooltip title="Verbindung trennen">
|
||||
<IconButton size="small" onClick={handleDisconnect}>
|
||||
<LinkOff fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isLoading && (
|
||||
<Box>
|
||||
{[1, 2, 3].map((n) => (
|
||||
<Box key={n} sx={{ mb: 1.5 }}>
|
||||
<Skeleton variant="text" width="60%" height={22} />
|
||||
<Skeleton variant="text" width="90%" height={18} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Nextcloud nicht erreichbar
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && !connected && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 3, gap: 2 }}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<CircularProgress size={32} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Warte auf Bestätigung...
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
Verbinde dein Nextcloud-Konto, um Chats zu sehen.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Cloud />}
|
||||
onClick={handleConnect}
|
||||
>
|
||||
Mit Nextcloud verbinden
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && connected && conversations.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Keine aktiven Chats
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && connected && conversations.length > 0 && (
|
||||
<Box>
|
||||
{conversations.map((conversation, index) => (
|
||||
<ConversationRow
|
||||
key={conversation.token}
|
||||
conversation={conversation}
|
||||
showDivider={index < conversations.length - 1}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextcloudTalkWidget;
|
||||
@@ -19,6 +19,7 @@ import UserProfile from '../components/dashboard/UserProfile';
|
||||
import NextcloudCard from '../components/dashboard/NextcloudCard';
|
||||
import VikunjaCard from '../components/dashboard/VikunjaCard';
|
||||
import BookstackCard from '../components/dashboard/BookstackCard';
|
||||
import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
|
||||
import StatsCard from '../components/dashboard/StatsCard';
|
||||
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
||||
import InspectionAlerts from '../components/vehicles/InspectionAlerts';
|
||||
@@ -207,6 +208,19 @@ function Dashboard() {
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Nextcloud Talk Widget */}
|
||||
<Grid item xs={12} md={6} lg={4}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '520ms' }}>
|
||||
<Box>
|
||||
<NextcloudTalkWidget />
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<Grid item xs={12}>
|
||||
{dataLoading ? (
|
||||
|
||||
33
frontend/src/services/nextcloud.ts
Normal file
33
frontend/src/services/nextcloud.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { api } from './api';
|
||||
import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData } from '../types/nextcloud.types';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const nextcloudApi = {
|
||||
getConversations(): Promise<NextcloudTalkData> {
|
||||
return api
|
||||
.get<ApiResponse<NextcloudTalkData>>('/api/nextcloud/talk')
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
connect(): Promise<NextcloudConnectData> {
|
||||
return api
|
||||
.post<ApiResponse<NextcloudConnectData>>('/api/nextcloud/talk/connect')
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
poll(pollToken: string, pollEndpoint: string): Promise<NextcloudPollData> {
|
||||
return api
|
||||
.post<ApiResponse<NextcloudPollData>>('/api/nextcloud/talk/poll', { pollToken, pollEndpoint })
|
||||
.then((r) => r.data.data);
|
||||
},
|
||||
|
||||
disconnect(): Promise<void> {
|
||||
return api
|
||||
.delete('/api/nextcloud/talk/connect')
|
||||
.then(() => undefined);
|
||||
},
|
||||
};
|
||||
29
frontend/src/types/nextcloud.types.ts
Normal file
29
frontend/src/types/nextcloud.types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface NextcloudConversation {
|
||||
token: string;
|
||||
displayName: string;
|
||||
unreadMessages: number;
|
||||
lastActivity: number;
|
||||
lastMessage: {
|
||||
text: string;
|
||||
author: string;
|
||||
timestamp: number;
|
||||
} | null;
|
||||
type: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface NextcloudTalkData {
|
||||
connected: boolean;
|
||||
totalUnread?: number;
|
||||
conversations?: NextcloudConversation[];
|
||||
}
|
||||
|
||||
export interface NextcloudConnectData {
|
||||
loginUrl: string;
|
||||
pollToken: string;
|
||||
pollEndpoint: string;
|
||||
}
|
||||
|
||||
export interface NextcloudPollData {
|
||||
completed: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user