This commit is contained in:
2025-11-28 18:03:34 +01:00
parent 30cd0c51f5
commit 6bc65c2a29
17 changed files with 3546 additions and 469 deletions

View File

@@ -14,7 +14,7 @@
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/react": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19",
"@microsoft/signalr": "^10.0.0",
"@microsoft/signalr": "^8.0.7",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"@mui/x-data-grid": "^8.20.0",
@@ -1263,16 +1263,16 @@
}
},
"node_modules/@microsoft/signalr": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz",
"integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
"ws": "^7.4.5"
}
},
"node_modules/@mui/core-downloads-tracker": {

View File

@@ -16,7 +16,7 @@
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/react": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19",
"@microsoft/signalr": "^10.0.0",
"@microsoft/signalr": "^8.0.7",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"@mui/x-data-grid": "^8.20.0",

View File

@@ -18,6 +18,7 @@ import CalendarioPage from "./pages/CalendarioPage";
import ReportTemplatesPage from "./pages/ReportTemplatesPage";
import ReportEditorPage from "./pages/ReportEditorPage";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext";
const queryClient = new QueryClient({
defaultOptions: {
@@ -59,29 +60,34 @@ function App() {
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<CssBaseline />
<BrowserRouter>
<RealTimeProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="calendario" element={<CalendarioPage />} />
<Route path="eventi" element={<EventiPage />} />
<Route path="eventi/:id" element={<EventoDetailPage />} />
<Route path="clienti" element={<ClientiPage />} />
<Route path="location" element={<LocationPage />} />
<Route path="articoli" element={<ArticoliPage />} />
<Route path="risorse" element={<RisorsePage />} />
<Route
path="report-templates"
element={<ReportTemplatesPage />}
/>
<Route path="report-editor" element={<ReportEditorPage />} />
<Route
path="report-editor/:id"
element={<ReportEditorPage />}
/>
</Route>
</Routes>
</RealTimeProvider>
<CollaborationProvider>
<RealTimeProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="calendario" element={<CalendarioPage />} />
<Route path="eventi" element={<EventiPage />} />
<Route path="eventi/:id" element={<EventoDetailPage />} />
<Route path="clienti" element={<ClientiPage />} />
<Route path="location" element={<LocationPage />} />
<Route path="articoli" element={<ArticoliPage />} />
<Route path="risorse" element={<RisorsePage />} />
<Route
path="report-templates"
element={<ReportTemplatesPage />}
/>
<Route
path="report-editor"
element={<ReportEditorPage />}
/>
<Route
path="report-editor/:id"
element={<ReportEditorPage />}
/>
</Route>
</Routes>
</RealTimeProvider>
</CollaborationProvider>
</BrowserRouter>
</LocalizationProvider>
</ThemeProvider>

View File

@@ -28,6 +28,7 @@ import {
Print as PrintIcon,
Close as CloseIcon,
} from "@mui/icons-material";
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
const DRAWER_WIDTH = 240;
const DRAWER_WIDTH_COLLAPSED = 64;
@@ -158,6 +159,9 @@ export default function Layout() {
>
{isMobile ? "Apollinare" : "Catering & Banqueting Management"}
</Typography>
{/* Collaboration Indicator */}
<CollaborationIndicator compact={isMobile} />
</Toolbar>
</AppBar>

View File

@@ -0,0 +1,461 @@
import { useState } from "react";
import {
Avatar,
AvatarGroup,
Badge,
Box,
Chip,
Collapse,
Divider,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemText,
Paper,
Popover,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import {
Circle as CircleIcon,
ExpandLess,
ExpandMore,
History as HistoryIcon,
People as PeopleIcon,
SignalWifiOff as DisconnectedIcon,
Wifi as ConnectedIcon,
WifiFind as ConnectingIcon,
} from "@mui/icons-material";
import { useCollaboration } from "../../contexts/CollaborationContext";
import type { Collaborator } from "../../services/collaboration";
interface CollaborationIndicatorProps {
/** Show only when in a room */
showOnlyInRoom?: boolean;
/** Show change history button */
showHistory?: boolean;
/** Compact mode for mobile */
compact?: boolean;
/** Click handler for collaborator avatar */
onCollaboratorClick?: (collaborator: Collaborator) => void;
}
export default function CollaborationIndicator({
showOnlyInRoom = true,
showHistory = true,
compact = false,
onCollaboratorClick,
}: CollaborationIndicatorProps) {
const theme = useTheme();
const {
isConnected,
isConnecting,
currentRoom,
localUserName,
localUserColor,
collaborators,
remoteSelections,
changeHistory,
} = useCollaboration();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [historyAnchorEl, setHistoryAnchorEl] = useState<HTMLElement | null>(
null,
);
const [showHistoryList, setShowHistoryList] = useState(true);
// Don't show if not in a room and showOnlyInRoom is true
if (showOnlyInRoom && !currentRoom) {
return null;
}
const handleCollaboratorsClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleHistoryClick = (event: React.MouseEvent<HTMLElement>) => {
setHistoryAnchorEl(event.currentTarget);
};
const handleHistoryClose = () => {
setHistoryAnchorEl(null);
};
const open = Boolean(anchorEl);
const historyOpen = Boolean(historyAnchorEl);
const totalUsers = collaborators.length + 1;
// Format timestamp
const formatRelativeTime = (date: Date): string => {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
if (diffSec < 5) return "ora";
if (diffSec < 60) return `${diffSec}s fa`;
if (diffMin < 60) return `${diffMin}m fa`;
if (diffHour < 24) return `${diffHour}h fa`;
return date.toLocaleDateString("it-IT");
};
// Get initials from name
const getInitials = (name: string): string => {
const parts = name.split(" ");
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{/* Connection indicator */}
<Tooltip
title={
isConnecting
? "Connessione in corso..."
: isConnected
? currentRoom
? `Collaborazione attiva: ${currentRoom}`
: "Connesso"
: "Disconnesso"
}
>
<Box
sx={{
display: "flex",
alignItems: "center",
color: isConnecting
? theme.palette.warning.main
: isConnected
? theme.palette.success.main
: theme.palette.error.main,
}}
>
{isConnecting ? (
<ConnectingIcon fontSize="small" />
) : isConnected ? (
<ConnectedIcon fontSize="small" />
) : (
<DisconnectedIcon fontSize="small" />
)}
</Box>
</Tooltip>
{/* Collaborators avatars */}
{currentRoom && (
<Tooltip
title={`${totalUsers} utente${totalUsers > 1 ? "i" : ""} connesso${totalUsers > 1 ? "i" : ""}`}
>
<Badge
badgeContent={totalUsers}
color="primary"
sx={{ cursor: "pointer" }}
onClick={handleCollaboratorsClick}
>
<AvatarGroup
max={compact ? 3 : 4}
sx={{
"& .MuiAvatar-root": {
width: compact ? 24 : 28,
height: compact ? 24 : 28,
fontSize: compact ? "0.65rem" : "0.75rem",
border: `2px solid ${theme.palette.background.paper}`,
cursor: "pointer",
},
}}
>
{/* Local user avatar */}
<Tooltip title={`${localUserName} (tu)`}>
<Avatar sx={{ bgcolor: localUserColor }}>
{getInitials(localUserName)}
</Avatar>
</Tooltip>
{/* Remote collaborators */}
{collaborators.map((collab) => (
<Tooltip
key={collab.connectionId}
title={`${collab.userName}${remoteSelections.get(collab.connectionId) ? " - sta modificando" : ""}`}
>
<Avatar
sx={{
bgcolor: collab.color,
boxShadow: remoteSelections.get(collab.connectionId)
? `0 0 0 2px ${collab.color}`
: undefined,
}}
>
{getInitials(collab.userName)}
</Avatar>
</Tooltip>
))}
</AvatarGroup>
</Badge>
</Tooltip>
)}
{/* History button */}
{showHistory && changeHistory.length > 0 && (
<Tooltip title="Cronologia modifiche">
<IconButton size="small" onClick={handleHistoryClick}>
<Badge
badgeContent={changeHistory.length}
color="secondary"
max={99}
>
<HistoryIcon fontSize="small" />
</Badge>
</IconButton>
</Tooltip>
)}
{/* Collaborators popover */}
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<Paper sx={{ minWidth: 280, maxWidth: 350, p: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
<PeopleIcon color="primary" />
<Typography variant="subtitle1" fontWeight="bold">
Collaboratori ({totalUsers})
</Typography>
</Box>
{currentRoom && (
<Chip
label={currentRoom}
size="small"
variant="outlined"
sx={{ mb: 2 }}
/>
)}
<List dense disablePadding>
{/* Local user */}
<ListItem
sx={{
bgcolor: theme.palette.action.selected,
borderRadius: 1,
mb: 0.5,
}}
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: localUserColor, width: 32, height: 32 }}>
{getInitials(localUserName)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{localUserName}
<Chip label="Tu" size="small" color="primary" />
</Box>
}
secondary="Connesso"
/>
</ListItem>
{/* Remote collaborators */}
{collaborators.map((collab) => {
const isEditing = remoteSelections.get(collab.connectionId);
return (
<ListItem
key={collab.connectionId}
sx={{
borderRadius: 1,
mb: 0.5,
cursor: onCollaboratorClick ? "pointer" : "default",
"&:hover": onCollaboratorClick
? { bgcolor: theme.palette.action.hover }
: {},
}}
onClick={() => onCollaboratorClick?.(collab)}
>
<ListItemAvatar>
<Badge
overlap="circular"
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
badgeContent={
isEditing ? (
<CircleIcon
sx={{
color: collab.color,
fontSize: 12,
animation: "pulse 1.5s infinite",
"@keyframes pulse": {
"0%": { opacity: 1 },
"50%": { opacity: 0.5 },
"100%": { opacity: 1 },
},
}}
/>
) : null
}
>
<Avatar
sx={{ bgcolor: collab.color, width: 32, height: 32 }}
>
{getInitials(collab.userName)}
</Avatar>
</Badge>
</ListItemAvatar>
<ListItemText
primary={collab.userName}
secondary={
isEditing ? (
<Typography
variant="caption"
sx={{ color: collab.color, fontWeight: 500 }}
>
Sta modificando...
</Typography>
) : (
"Online"
)
}
/>
</ListItem>
);
})}
{collaborators.length === 0 && (
<ListItem>
<ListItemText
secondary="Nessun altro collaboratore connesso"
sx={{ textAlign: "center", color: "text.secondary" }}
/>
</ListItem>
)}
</List>
</Paper>
</Popover>
{/* History popover */}
<Popover
open={historyOpen}
anchorEl={historyAnchorEl}
onClose={handleHistoryClose}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
>
<Paper
sx={{
minWidth: 320,
maxWidth: 400,
maxHeight: 400,
overflow: "auto",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
pb: 1,
position: "sticky",
top: 0,
bgcolor: "background.paper",
zIndex: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<HistoryIcon color="primary" />
<Typography variant="subtitle1" fontWeight="bold">
Modifiche Recenti
</Typography>
</Box>
<IconButton
size="small"
onClick={() => setShowHistoryList(!showHistoryList)}
>
{showHistoryList ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Box>
<Collapse in={showHistoryList}>
<List dense disablePadding sx={{ px: 1, pb: 1 }}>
{changeHistory.slice(0, 30).map((entry, index) => (
<Box key={entry.id}>
<ListItem
sx={{
py: 0.5,
borderLeft: `3px solid ${entry.userColor}`,
pl: 1,
ml: 1,
}}
>
<ListItemText
primary={
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="body2" fontWeight={500}>
{entry.description}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, flexShrink: 0 }}
>
{formatRelativeTime(entry.timestamp)}
</Typography>
</Box>
}
secondary={
<Typography
variant="caption"
sx={{ color: entry.userColor }}
>
{entry.userName}
</Typography>
}
/>
</ListItem>
{index < changeHistory.length - 1 && (
<Divider variant="inset" component="li" sx={{ ml: 2 }} />
)}
</Box>
))}
{changeHistory.length === 0 && (
<ListItem>
<ListItemText
secondary="Nessuna modifica recente"
sx={{ textAlign: "center" }}
/>
</ListItem>
)}
{changeHistory.length > 30 && (
<ListItem>
<ListItemText
secondary={`...e altre ${changeHistory.length - 30} modifiche`}
sx={{ textAlign: "center", fontStyle: "italic" }}
/>
</ListItem>
)}
</List>
</Collapse>
</Paper>
</Popover>
</Box>
);
}

View File

@@ -0,0 +1,177 @@
import { Box, Typography, Fade } from "@mui/material";
import { Navigation as CursorIcon } from "@mui/icons-material";
import { useCollaboration, useRemoteCursorsForView } from "../../contexts/CollaborationContext";
interface RemoteCursorsProps {
/** Current view ID to filter cursors */
viewId?: string | null;
/** Zoom level for coordinate transformation */
zoom?: number;
/** Container offset X (for absolute positioning) */
offsetX?: number;
/** Container offset Y (for absolute positioning) */
offsetY?: number;
/** Whether cursors are in mm coordinates (needs conversion to px) */
coordinatesInMm?: boolean;
/** MM to PX ratio if coordinatesInMm is true */
mmToPxRatio?: number;
}
/**
* Overlay component that displays remote collaborator cursors
* Should be placed in a relative positioned container
*/
export default function RemoteCursors({
viewId = null,
zoom = 1,
offsetX = 0,
offsetY = 0,
coordinatesInMm = false,
mmToPxRatio = 3.7795275591,
}: RemoteCursorsProps) {
const cursors = useRemoteCursorsForView(viewId);
if (cursors.length === 0) {
return null;
}
return (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
overflow: "hidden",
zIndex: 1000,
}}
>
{cursors.map((cursor) => {
// Convert coordinates if needed
let x = cursor.x;
let y = cursor.y;
if (coordinatesInMm) {
x = cursor.x * mmToPxRatio;
y = cursor.y * mmToPxRatio;
}
// Apply zoom and offset
x = x * zoom + offsetX;
y = y * zoom + offsetY;
return (
<Fade key={cursor.connectionId} in={true} timeout={200}>
<Box
sx={{
position: "absolute",
left: x,
top: y,
transform: "translate(-2px, -2px)",
transition: "left 0.05s linear, top 0.05s linear",
}}
>
{/* Cursor icon */}
<CursorIcon
sx={{
fontSize: 20,
color: cursor.color,
filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.3))",
transform: "rotate(-45deg)",
}}
/>
{/* User name label */}
<Box
sx={{
position: "absolute",
left: 16,
top: 12,
bgcolor: cursor.color,
color: "white",
px: 0.75,
py: 0.25,
borderRadius: 0.5,
fontSize: "0.65rem",
fontWeight: 500,
whiteSpace: "nowrap",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
maxWidth: 120,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{cursor.userName}
</Box>
</Box>
</Fade>
);
})}
</Box>
);
}
/**
* Component to highlight items selected by remote users
*/
interface RemoteSelectionHighlightProps {
itemId: string;
children: React.ReactNode;
}
export function RemoteSelectionHighlight({ itemId, children }: RemoteSelectionHighlightProps) {
const { remoteSelections, collaborators } = useCollaboration();
// Find if any collaborator has this item selected
let selectedBy: { userName: string; color: string } | null = null;
for (const [connectionId, selectedId] of remoteSelections.entries()) {
if (selectedId === itemId) {
const collaborator = collaborators.find((c) => c.connectionId === connectionId);
if (collaborator) {
selectedBy = { userName: collaborator.userName, color: collaborator.color };
break;
}
}
}
if (!selectedBy) {
return <>{children}</>;
}
return (
<Box
sx={{
position: "relative",
outline: `2px solid ${selectedBy.color}`,
outlineOffset: 2,
borderRadius: 1,
}}
>
{children}
{/* Selection indicator */}
<Typography
variant="caption"
sx={{
position: "absolute",
top: -20,
left: 0,
bgcolor: selectedBy.color,
color: "white",
px: 0.75,
py: 0.25,
borderRadius: 0.5,
fontSize: "0.6rem",
fontWeight: 500,
whiteSpace: "nowrap",
zIndex: 1,
}}
>
{selectedBy.userName}
</Typography>
</Box>
);
}

View File

@@ -32,6 +32,15 @@ export interface ContextMenuEvent {
elementId: string | null;
}
// Remote cursor info
export interface RemoteCursor {
x: number;
y: number;
pageId: string | null;
color: string;
userName: string;
}
interface EditorCanvasProps {
template: AprtTemplate;
selectedElementId: string | null;
@@ -44,6 +53,16 @@ interface EditorCanvasProps {
gridSize: number;
snapOptions: SnapOptions;
onContextMenu?: (event: ContextMenuEvent) => void;
/** Callback when cursor moves on canvas (for collaboration) */
onCursorMove?: (x: number, y: number) => void;
/** Remote cursors from other collaborators */
remoteCursors?: Map<string, RemoteCursor>;
/** Remote selections from other collaborators (connectionId -> elementId) */
remoteSelections?: Map<string, string | null>;
/** Map of connectionId -> collaborator info for colors */
collaboratorColors?: Map<string, { color: string; userName: string }>;
/** Current page ID for filtering remote cursors */
currentPageId?: string;
}
export interface EditorCanvasRef {
@@ -68,6 +87,12 @@ const EditorCanvas = forwardRef<EditorCanvasRef, EditorCanvasProps>(
gridSize,
snapOptions,
onContextMenu,
onCursorMove,
// These props are reserved for future remote cursor rendering on canvas
// remoteCursors,
// remoteSelections,
// collaboratorColors,
// currentPageId,
},
ref,
) => {
@@ -639,12 +664,16 @@ const EditorCanvas = forwardRef<EditorCanvasRef, EditorCanvasProps>(
(e: { e: MouseEvent }) => {
if (!fabricRef.current) return;
const pointer = fabricRef.current.getScenePoint(e.e);
setCursorPosition({
x: Math.round((pxToMm(pointer.x) / zoom) * 10) / 10,
y: Math.round((pxToMm(pointer.y) / zoom) * 10) / 10,
});
const xMm = Math.round((pxToMm(pointer.x) / zoom) * 10) / 10;
const yMm = Math.round((pxToMm(pointer.y) / zoom) * 10) / 10;
setCursorPosition({ x: xMm, y: yMm });
// Send cursor position for collaboration
if (onCursorMove) {
onCursorMove(xMm, yMm);
}
},
[zoom],
[zoom, onCursorMove],
);
// Keyboard navigation

View File

@@ -0,0 +1,632 @@
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
type ReactNode,
} from "react";
import {
collaborationService,
type Collaborator,
type RoomState,
type DataChangeMessage,
type ItemCreatedMessage,
type ItemDeletedMessage,
type BatchOperationMessage,
type SelectionChangedMessage,
type CursorMovedMessage,
type ChangeHistoryEntry,
type SyncRequestMessage,
type SyncDataMessage,
type DataSavedMessage,
type UserLeftMessage,
getOrCreateUserName,
getColorForUser,
} from "../services/collaboration";
// ==================== TYPES ====================
export interface RemoteCursor {
connectionId: string;
userName: string;
color: string;
x: number;
y: number;
viewId: string | null;
}
export interface CollaborationContextValue {
// Connection state
isConnected: boolean;
isConnecting: boolean;
currentRoom: string | null;
// Local user
localUserName: string;
localUserColor: string;
connectionId: string | null;
// Collaborators
collaborators: Collaborator[];
remoteSelections: Map<string, string | null>;
remoteCursors: Map<string, RemoteCursor>;
// Change history
changeHistory: ChangeHistoryEntry[];
// Room management
joinRoom: (roomKey: string, metadata?: unknown) => Promise<void>;
leaveRoom: () => Promise<void>;
switchRoom: (roomKey: string, metadata?: unknown) => Promise<void>;
// Data operations (generic)
sendDataChanged: (
itemId: string,
itemType: string,
changeType: string,
newValue: unknown,
fieldPath?: string,
) => void;
sendItemCreated: (
itemId: string,
itemType: string,
item: unknown,
parentId?: string,
index?: number,
) => void;
sendItemDeleted: (itemId: string, itemType: string) => void;
sendBatchOperation: (
operationType: string,
itemType: string,
data: unknown,
) => void;
// Presence
sendSelectionChanged: (itemId: string | null) => void;
sendCursorMoved: (x: number, y: number, viewId?: string | null) => void;
sendViewChanged: (viewId: string) => void;
sendUserTyping: (itemId: string | null, isTyping: boolean) => void;
// Sync
requestSync: () => void;
sendSync: (targetConnectionId: string, dataJson: string) => void;
sendDataSaved: () => void;
// Event subscriptions for component-specific handlers
onDataChanged: (callback: (msg: DataChangeMessage) => void) => () => void;
onItemCreated: (callback: (msg: ItemCreatedMessage) => void) => () => void;
onItemDeleted: (callback: (msg: ItemDeletedMessage) => void) => () => void;
onBatchOperation: (
callback: (msg: BatchOperationMessage) => void,
) => () => void;
onSyncRequested: (callback: (msg: SyncRequestMessage) => void) => () => void;
onSyncReceived: (callback: (msg: SyncDataMessage) => void) => () => void;
onDataSaved: (callback: (msg: DataSavedMessage) => void) => () => void;
}
const CollaborationContext = createContext<CollaborationContextValue | null>(
null,
);
// ==================== PROVIDER ====================
interface CollaborationProviderProps {
children: ReactNode;
/** Auto-connect on mount */
autoConnect?: boolean;
}
export function CollaborationProvider({
children,
autoConnect = true,
}: CollaborationProviderProps) {
// Connection state
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [currentRoom, setCurrentRoom] = useState<string | null>(null);
// Local user
const localUserName = useRef(getOrCreateUserName());
const localUserColor = useRef(getColorForUser(localUserName.current));
const [connectionId, setConnectionId] = useState<string | null>(null);
// Collaborators
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
const [remoteSelections, setRemoteSelections] = useState<
Map<string, string | null>
>(new Map());
const [remoteCursors, setRemoteCursors] = useState<Map<string, RemoteCursor>>(
new Map(),
);
// Change history
const [changeHistory, setChangeHistory] = useState<ChangeHistoryEntry[]>([]);
// Auto-connect on mount
// Note: We don't disconnect on unmount because:
// 1. React Strict Mode double-mounts components, causing connection interruption
// 2. The collaboration service is a singleton that persists across navigations
// 3. SignalR has built-in reconnection handling
useEffect(() => {
if (autoConnect) {
collaborationService.connect().catch(() => {
// Connection errors are logged in the service, ignore here
});
}
// No cleanup - connection persists for the app lifetime
}, [autoConnect]);
// Subscribe to connection state changes
useEffect(() => {
const unsubscribe = collaborationService.onConnectionStateChanged(
({ connected, connecting }) => {
setIsConnected(connected);
setIsConnecting(connecting);
setConnectionId(collaborationService.connectionId);
},
);
return unsubscribe;
}, []);
// Subscribe to room state (on join)
useEffect(() => {
const unsubscribe = collaborationService.onRoomState((state: RoomState) => {
setCurrentRoom(state.roomKey);
setCollaborators(state.collaborators);
// Initialize remote selections and cursors
const selections = new Map<string, string | null>();
const cursors = new Map<string, RemoteCursor>();
state.collaborators.forEach((c) => {
selections.set(c.connectionId, c.selectedItemId);
if (c.cursorX != null && c.cursorY != null) {
cursors.set(c.connectionId, {
connectionId: c.connectionId,
userName: c.userName,
color: c.color,
x: c.cursorX,
y: c.cursorY,
viewId: c.currentViewId,
});
}
});
setRemoteSelections(selections);
setRemoteCursors(cursors);
});
return unsubscribe;
}, []);
// Subscribe to user join/leave
useEffect(() => {
const unsubJoin = collaborationService.onUserJoined(
(collaborator: Collaborator) => {
setCollaborators((prev) => {
if (prev.some((c) => c.connectionId === collaborator.connectionId)) {
return prev;
}
return [...prev, collaborator];
});
},
);
const unsubLeave = collaborationService.onUserLeft(
(message: UserLeftMessage) => {
setCollaborators((prev) =>
prev.filter((c) => c.connectionId !== message.connectionId),
);
setRemoteSelections((prev) => {
const newMap = new Map(prev);
newMap.delete(message.connectionId);
return newMap;
});
setRemoteCursors((prev) => {
const newMap = new Map(prev);
newMap.delete(message.connectionId);
return newMap;
});
},
);
return () => {
unsubJoin();
unsubLeave();
};
}, []);
// Subscribe to selection changes
useEffect(() => {
const unsubscribe = collaborationService.onSelectionChanged(
(message: SelectionChangedMessage) => {
setRemoteSelections((prev) => {
const newMap = new Map(prev);
newMap.set(message.connectionId, message.itemId);
return newMap;
});
},
);
return unsubscribe;
}, []);
// Subscribe to cursor movements
useEffect(() => {
const unsubscribe = collaborationService.onCursorMoved(
(message: CursorMovedMessage) => {
const collaborator = collaborationService.getCollaborator(
message.connectionId,
);
setRemoteCursors((prev) => {
const newMap = new Map(prev);
newMap.set(message.connectionId, {
connectionId: message.connectionId,
userName: collaborator?.userName || "Utente",
color: collaborator?.color || "#888",
x: message.x,
y: message.y,
viewId: message.viewId,
});
return newMap;
});
},
);
return unsubscribe;
}, []);
// Subscribe to change history
useEffect(() => {
const unsubscribe = collaborationService.onChangeHistoryUpdated(
(history) => {
setChangeHistory(history);
},
);
return unsubscribe;
}, []);
// Room management callbacks
const joinRoom = useCallback(async (roomKey: string, metadata?: unknown) => {
await collaborationService.joinRoom(
roomKey,
localUserName.current,
metadata,
);
setCurrentRoom(roomKey);
}, []);
const leaveRoom = useCallback(async () => {
await collaborationService.leaveRoom();
setCurrentRoom(null);
setCollaborators([]);
setRemoteSelections(new Map());
setRemoteCursors(new Map());
}, []);
const switchRoom = useCallback(
async (roomKey: string, metadata?: unknown) => {
await collaborationService.switchRoom(roomKey, metadata);
setCurrentRoom(roomKey);
setCollaborators([]);
setRemoteSelections(new Map());
setRemoteCursors(new Map());
},
[],
);
// Data operation callbacks
const sendDataChanged = useCallback(
(
itemId: string,
itemType: string,
changeType: string,
newValue: unknown,
fieldPath?: string,
) => {
collaborationService.sendDataChanged(
itemId,
itemType,
changeType,
newValue,
fieldPath,
);
},
[],
);
const sendItemCreated = useCallback(
(
itemId: string,
itemType: string,
item: unknown,
parentId?: string,
index?: number,
) => {
collaborationService.sendItemCreated(
itemId,
itemType,
item,
parentId,
index,
);
},
[],
);
const sendItemDeleted = useCallback((itemId: string, itemType: string) => {
collaborationService.sendItemDeleted(itemId, itemType);
}, []);
const sendBatchOperation = useCallback(
(operationType: string, itemType: string, data: unknown) => {
collaborationService.sendBatchOperation(operationType, itemType, data);
},
[],
);
// Presence callbacks
const sendSelectionChanged = useCallback((itemId: string | null) => {
collaborationService.sendSelectionChanged(itemId);
}, []);
const sendCursorMoved = useCallback(
(x: number, y: number, viewId?: string | null) => {
collaborationService.sendCursorMoved(x, y, viewId);
},
[],
);
const sendViewChanged = useCallback((viewId: string) => {
collaborationService.sendViewChanged(viewId);
}, []);
const sendUserTyping = useCallback(
(itemId: string | null, isTyping: boolean) => {
collaborationService.sendUserTyping(itemId, isTyping);
},
[],
);
// Sync callbacks
const requestSync = useCallback(() => {
collaborationService.requestSync();
}, []);
const sendSync = useCallback(
(targetConnectionId: string, dataJson: string) => {
collaborationService.sendSync(targetConnectionId, dataJson);
},
[],
);
const sendDataSaved = useCallback(() => {
collaborationService.sendDataSaved();
}, []);
// Event subscription pass-through
const onDataChanged = useCallback(
(callback: (msg: DataChangeMessage) => void) => {
return collaborationService.onDataChanged(callback);
},
[],
);
const onItemCreated = useCallback(
(callback: (msg: ItemCreatedMessage) => void) => {
return collaborationService.onItemCreated(callback);
},
[],
);
const onItemDeleted = useCallback(
(callback: (msg: ItemDeletedMessage) => void) => {
return collaborationService.onItemDeleted(callback);
},
[],
);
const onBatchOperation = useCallback(
(callback: (msg: BatchOperationMessage) => void) => {
return collaborationService.onBatchOperation(callback);
},
[],
);
const onSyncRequested = useCallback(
(callback: (msg: SyncRequestMessage) => void) => {
return collaborationService.onSyncRequested(callback);
},
[],
);
const onSyncReceived = useCallback(
(callback: (msg: SyncDataMessage) => void) => {
return collaborationService.onSyncReceived(callback);
},
[],
);
const onDataSaved = useCallback(
(callback: (msg: DataSavedMessage) => void) => {
return collaborationService.onDataSaved(callback);
},
[],
);
const value: CollaborationContextValue = {
// Connection state
isConnected,
isConnecting,
currentRoom,
// Local user
localUserName: localUserName.current,
localUserColor: localUserColor.current,
connectionId,
// Collaborators
collaborators,
remoteSelections,
remoteCursors,
// Change history
changeHistory,
// Room management
joinRoom,
leaveRoom,
switchRoom,
// Data operations
sendDataChanged,
sendItemCreated,
sendItemDeleted,
sendBatchOperation,
// Presence
sendSelectionChanged,
sendCursorMoved,
sendViewChanged,
sendUserTyping,
// Sync
requestSync,
sendSync,
sendDataSaved,
// Event subscriptions
onDataChanged,
onItemCreated,
onItemDeleted,
onBatchOperation,
onSyncRequested,
onSyncReceived,
onDataSaved,
};
return (
<CollaborationContext.Provider value={value}>
{children}
</CollaborationContext.Provider>
);
}
// ==================== HOOKS ====================
/**
* Hook to access the collaboration context
*/
export function useCollaboration(): CollaborationContextValue {
const context = useContext(CollaborationContext);
if (!context) {
throw new Error(
"useCollaboration must be used within a CollaborationProvider",
);
}
return context;
}
/**
* Hook to automatically join a room when a component mounts
*/
export function useCollaborationRoom(
roomKey: string | null,
options?: {
metadata?: unknown;
enabled?: boolean;
},
): CollaborationContextValue {
const collaboration = useCollaboration();
const { enabled = true, metadata } = options || {};
// Use refs to avoid dependency changes causing re-runs
const joinRoomRef = useRef(collaboration.joinRoom);
const leaveRoomRef = useRef(collaboration.leaveRoom);
const metadataRef = useRef(metadata);
// Keep refs updated
joinRoomRef.current = collaboration.joinRoom;
leaveRoomRef.current = collaboration.leaveRoom;
metadataRef.current = metadata;
// Track if we successfully joined a room
const joinedRoomRef = useRef<string | null>(null);
useEffect(() => {
if (!enabled || !roomKey) return;
// Use a flag to prevent race conditions
let cancelled = false;
const doJoin = async () => {
try {
if (!cancelled) {
await joinRoomRef.current(roomKey, metadataRef.current);
if (!cancelled) {
joinedRoomRef.current = roomKey;
}
}
} catch (error) {
if (!cancelled) {
console.error("[useCollaborationRoom] Failed to join room:", error);
}
}
};
doJoin();
return () => {
cancelled = true;
// Only leave if we actually joined this room
if (joinedRoomRef.current === roomKey) {
joinedRoomRef.current = null;
leaveRoomRef.current().catch((err) => {
// Silently ignore leave errors - connection may already be closed
console.log(
"[useCollaborationRoom] Leave room:",
err?.message || "ok",
);
});
}
};
}, [roomKey, enabled]); // Only re-run when roomKey or enabled changes
return collaboration;
}
/**
* Hook to get cursors filtered by current view
*/
export function useRemoteCursorsForView(viewId: string | null): RemoteCursor[] {
const { remoteCursors } = useCollaboration();
return Array.from(remoteCursors.values()).filter(
(cursor) => viewId === null || cursor.viewId === viewId,
);
}
/**
* Hook to check if an item is selected by a remote user
*/
export function useRemoteSelection(itemId: string): {
isSelected: boolean;
selectedBy: Collaborator | null;
} {
const { remoteSelections, collaborators } = useCollaboration();
for (const [connectionId, selectedId] of remoteSelections.entries()) {
if (selectedId === itemId) {
const collaborator =
collaborators.find((c) => c.connectionId === connectionId) || null;
return { isSelected: true, selectedBy: collaborator };
}
}
return { isSelected: false, selectedBy: null };
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
useQuery,
@@ -8,6 +8,12 @@ import {
} from "@tanstack/react-query";
import { v4 as uuidv4 } from "uuid";
import { useHistory } from "../hooks/useHistory";
import { useCollaborationRoom } from "../contexts/CollaborationContext";
import type {
DataChangeMessage,
ItemCreatedMessage,
ItemDeletedMessage,
} from "../services/collaboration";
import {
Box,
CircularProgress,
@@ -172,6 +178,16 @@ export default function ReportEditorPage() {
// Auto-save feature - enabled by default
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
// ============ COLLABORATION (using global context) ============
// Room key format: "report-template:{id}"
const roomKey = id ? `report-template:${id}` : null;
const collaboration = useCollaborationRoom(roomKey, {
enabled: !isNew && !!id,
});
// Flag to prevent re-broadcasting received changes
const isApplyingRemoteChange = useRef(false);
// Update zoom on screen size change
useEffect(() => {
if (isMobile) {
@@ -183,6 +199,171 @@ export default function ReportEditorPage() {
}
}, [isMobile, isTablet]);
// ============ COLLABORATION EFFECTS ============
// The collaboration context handles connection, room joining, and presence automatically.
// We only need to subscribe to data change events and send our changes.
// Subscribe to remote data changes
useEffect(() => {
if (!collaboration.isConnected || !collaboration.currentRoom) return;
const unsubscribers: (() => void)[] = [];
// Element/data changed by remote user
unsubscribers.push(
collaboration.onDataChanged((message: DataChangeMessage) => {
if (message.itemType !== "element") return;
isApplyingRemoteChange.current = true;
historyActions.setWithoutHistory((prev) => ({
...prev,
elements: prev.elements.map((el) =>
el.id === message.itemId
? { ...el, ...(message.newValue as Partial<AprtElement>) }
: el,
),
}));
setTimeout(() => {
isApplyingRemoteChange.current = false;
}, 0);
}),
);
// Element added by remote user
unsubscribers.push(
collaboration.onItemCreated((message: ItemCreatedMessage) => {
if (message.itemType !== "element") return;
isApplyingRemoteChange.current = true;
historyActions.setWithoutHistory((prev) => ({
...prev,
elements: [...prev.elements, message.item as AprtElement],
}));
setTimeout(() => {
isApplyingRemoteChange.current = false;
}, 0);
}),
);
// Element deleted by remote user
unsubscribers.push(
collaboration.onItemDeleted((message: ItemDeletedMessage) => {
if (message.itemType !== "element") return;
isApplyingRemoteChange.current = true;
historyActions.setWithoutHistory((prev) => ({
...prev,
elements: prev.elements.filter((el) => el.id !== message.itemId),
}));
// Clear selection if deleted element was selected
if (selectedElementId === message.itemId) {
setSelectedElementId(null);
}
setTimeout(() => {
isApplyingRemoteChange.current = false;
}, 0);
}),
);
// Page changes by remote user
unsubscribers.push(
collaboration.onDataChanged((message: DataChangeMessage) => {
if (message.itemType !== "page") return;
isApplyingRemoteChange.current = true;
historyActions.setWithoutHistory((prev) => {
switch (message.changeType) {
case "added":
return {
...prev,
pages: [...prev.pages, message.newValue as AprtPage],
};
case "deleted":
return {
...prev,
pages: prev.pages.filter((p) => p.id !== message.itemId),
elements: prev.elements.filter(
(e) => e.pageId !== message.itemId,
),
};
case "renamed":
return {
...prev,
pages: prev.pages.map((p) =>
p.id === message.itemId
? { ...p, name: message.newValue as string }
: p,
),
};
case "reordered":
return {
...prev,
pages: message.newValue as AprtPage[],
};
case "settings":
return {
...prev,
pages: prev.pages.map((p) =>
p.id === message.itemId
? { ...p, ...(message.newValue as Partial<AprtPage>) }
: p,
),
};
default:
return prev;
}
});
setTimeout(() => {
isApplyingRemoteChange.current = false;
}, 0);
}),
);
// Template saved by remote user
unsubscribers.push(
collaboration.onDataSaved((message) => {
console.log(
"[Collaboration] Received DataSaved from:",
message.savedBy,
);
setSnackbar({
open: true,
message: `${message.savedBy} ha salvato il template`,
severity: "success",
});
queryClient.invalidateQueries({ queryKey: ["report-template", id] });
}),
);
// Sync requested - send current template to requester
unsubscribers.push(
collaboration.onSyncRequested((request) => {
collaboration.sendSync(request.requesterId, JSON.stringify(template));
}),
);
return () => {
unsubscribers.forEach((unsub) => unsub());
};
}, [
collaboration,
historyActions,
selectedElementId,
template,
queryClient,
id,
]);
// Send selection changes to collaborators
useEffect(() => {
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendSelectionChanged(selectedElementId);
}
}, [collaboration, selectedElementId]);
// Send view/page navigation to collaborators
useEffect(() => {
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendViewChanged(currentPageId);
}
}, [collaboration, currentPageId]);
// Load existing template
const { data: existingTemplate, isLoading: isLoadingTemplate } = useQuery({
queryKey: ["report-template", id],
@@ -347,6 +528,19 @@ export default function ReportEditorPage() {
setSaveDialog(false);
// Mark current state as saved
setLastSavedUndoCount(templateHistory.undoCount);
// Notify collaborators of save
console.log(
"[AutoSave] Save success, collaboration.isConnected:",
collaboration.isConnected,
"currentRoom:",
collaboration.currentRoom,
);
if (collaboration.isConnected && collaboration.currentRoom) {
console.log("[AutoSave] Sending DataSaved notification");
collaboration.sendDataSaved();
}
if (isNew) {
navigate(`/report-editor/${result.id}`, { replace: true });
}
@@ -415,10 +609,15 @@ export default function ReportEditorPage() {
pages: [...prev.pages, newPage],
}));
// Send to collaborators
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendDataChanged(newPageId, "page", "added", newPage);
}
// Switch to the new page
setCurrentPageId(newPageId);
setSelectedElementId(null);
}, [template.pages.length, historyActions]);
}, [template.pages.length, historyActions, collaboration]);
// Duplicate page with all its elements
const handleDuplicatePage = useCallback(
@@ -465,6 +664,11 @@ export default function ReportEditorPage() {
const pageIndex = template.pages.findIndex((p) => p.id === pageId);
// Send to collaborators before deleting
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendDataChanged(pageId, "page", "deleted", null);
}
historyActions.set((prev) => ({
...prev,
pages: prev.pages.filter((p) => p.id !== pageId),
@@ -481,7 +685,7 @@ export default function ReportEditorPage() {
}
setSelectedElementId(null);
},
[template.pages, historyActions],
[template.pages, historyActions, collaboration],
);
// Rename page
@@ -493,8 +697,13 @@ export default function ReportEditorPage() {
p.id === pageId ? { ...p, name: newName } : p,
),
}));
// Send to collaborators
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendDataChanged(pageId, "page", "renamed", newName);
}
},
[historyActions],
[historyActions, collaboration],
);
// Move page up or down
@@ -578,12 +787,17 @@ export default function ReportEditorPage() {
}));
setSelectedElementId(newElement.id);
// Send to collaborators
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendItemCreated(newElement.id, "element", newElement);
}
// On mobile, open properties panel after adding element
if (isMobile) {
setMobilePanel("properties");
}
},
[historyActions, currentPageId, isMobile],
[historyActions, currentPageId, isMobile, collaboration],
);
// Update element without history (for continuous updates like dragging)
@@ -608,8 +822,13 @@ export default function ReportEditorPage() {
el.id === elementId ? { ...el, ...updates } : el,
),
}));
// Send to collaborators
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendDataChanged(elementId, "element", "full", updates);
}
},
[historyActions],
[historyActions, collaboration],
);
// Handle image selection from dialog
@@ -696,12 +915,18 @@ export default function ReportEditorPage() {
// Delete element
const handleDeleteElement = useCallback(() => {
if (!selectedElementId) return;
// Send to collaborators before deleting
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
collaboration.sendItemDeleted(selectedElementId, "element");
}
historyActions.set((prev) => ({
...prev,
elements: prev.elements.filter((el) => el.id !== selectedElementId),
}));
setSelectedElementId(null);
}, [selectedElementId, historyActions]);
}, [selectedElementId, historyActions, collaboration]);
// Copy element
const handleCopyElement = useCallback(() => {
@@ -1228,29 +1453,37 @@ export default function ReportEditorPage() {
]);
// Auto-save effect - saves after 1 second of inactivity when there are unsaved changes
// Use refs to avoid the effect re-running on every render due to saveMutation changing
const saveMutationRef = useRef(saveMutation);
saveMutationRef.current = saveMutation;
const templateRef = useRef(template);
templateRef.current = template;
const templateInfoRef = useRef(templateInfo);
templateInfoRef.current = templateInfo;
useEffect(() => {
if (
!autoSaveEnabled ||
!hasUnsavedChanges ||
isNew ||
saveMutation.isPending
saveMutationRef.current.isPending
) {
return;
}
const timeoutId = setTimeout(() => {
saveMutation.mutate({ template, info: templateInfo });
if (!saveMutationRef.current.isPending) {
saveMutationRef.current.mutate({
template: templateRef.current,
info: templateInfoRef.current,
});
}
}, 1000); // 1 second debounce
return () => clearTimeout(timeoutId);
}, [
autoSaveEnabled,
hasUnsavedChanges,
isNew,
template,
templateInfo,
saveMutation,
]);
}, [autoSaveEnabled, hasUnsavedChanges, isNew]);
if (isLoadingTemplate && id) {
return (

File diff suppressed because it is too large Load Diff