Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 89 additions & 7 deletions frontend/src/components/CollaborativeCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
'use client';

import {
useAwareness,
useCanvasCollaboration,
useSharedCanvas,
} from '@/hooks/useCanvasCollaboration';
import { useCollaborativeEditor } from '@/hooks/useCollaborativeEditor';
import { useAwareness, useSharedCanvas } from '@/hooks/useCanvasCollaboration';
import { ReconnectBanner } from '@/components/collaboration/ReconnectBanner';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
Expand All @@ -29,8 +27,20 @@ interface CollaborativeCanvasProps {
export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: CollaborativeCanvasProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const [isExporting, setIsExporting] = useState(false);
const [showConflictResolver, setShowConflictResolver] = useState(false);

// Use the safe reconnect hook instead of the plain useCanvasCollaboration.
const {
doc,
awareness,
isConnected,
reconnectStatus,
hasConflict,
pendingUpdateCount,
reconnect,
dismissConflict,
} = useCollaborativeEditor(roomId, userId);

const { doc, awareness, isConnected } = useCanvasCollaboration(roomId, userId);
const {
nodes,
edges,
Expand All @@ -40,6 +50,7 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
addEdge: addCanvasEdge,
deleteEdge,
} = useSharedCanvas(doc);

const remoteUsers = useAwareness(awareness);

useEffect(() => {
Expand Down Expand Up @@ -226,6 +237,21 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora

return (
<div className="flex h-full flex-col">
{/* ----------------------------------------------------------------- */}
{/* Reconnect / conflict banner (zero-height when not needed) */}
{/* ----------------------------------------------------------------- */}
<ReconnectBanner
status={reconnectStatus}
pendingUpdateCount={pendingUpdateCount}
hasConflict={hasConflict}
onReconnect={reconnect}
onReviewConflict={() => setShowConflictResolver(true)}
onDismissConflict={dismissConflict}
/>

{/* ----------------------------------------------------------------- */}
{/* Toolbar */}
{/* ----------------------------------------------------------------- */}
<div className="flex flex-col gap-3 border-b border-gray-200 bg-white px-6 py-4 md:flex-row md:items-center md:justify-between dark:border-gray-700 dark:bg-gray-900">
<div className="space-y-2">
<div className="flex items-center gap-3">
Expand All @@ -234,7 +260,8 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
</h1>
<span
className={`h-3 w-3 rounded-full ${isConnected ? 'bg-emerald-500' : 'bg-rose-500'}`}
title={isConnected ? 'Connected' : 'Disconnected'}
aria-label={isConnected ? 'Connected' : 'Disconnected'}
title={isConnected ? 'Connected' : reconnectStatus === 'reconnecting' ? 'Reconnecting…' : 'Disconnected'}
/>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Expand Down Expand Up @@ -298,6 +325,9 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
</div>
</div>

{/* ----------------------------------------------------------------- */}
{/* Collaborator presence bar */}
{/* ----------------------------------------------------------------- */}
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
<span>
{remoteUsers.length > 0
Expand All @@ -311,13 +341,17 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: user.color }}
title={user.name}
aria-label={`Collaborator: ${user.name}`}
>
{user.name.charAt(0)}
</span>
))}
</div>
</div>

{/* ----------------------------------------------------------------- */}
{/* Canvas */}
{/* ----------------------------------------------------------------- */}
<div ref={canvasRef} className="relative flex-1 overflow-hidden bg-slate-950">
{doc ? (
<ReactFlow
Expand Down Expand Up @@ -346,6 +380,54 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
</div>
)}
</div>

{/* ----------------------------------------------------------------- */}
{/* Conflict resolver modal (lazy – only rendered when triggered) */}
{/* ----------------------------------------------------------------- */}
{showConflictResolver && doc && (
<div
role="dialog"
aria-modal="true"
aria-label="Conflict review"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div className="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-auto rounded-xl border border-zinc-700 bg-zinc-900 p-6 shadow-2xl">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Review Remote Changes</h2>
<button
type="button"
aria-label="Close conflict review"
onClick={() => {
setShowConflictResolver(false);
dismissConflict();
}}
className="rounded p-1 text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
<p className="mt-2 text-sm text-zinc-400">
The shared canvas was updated while you were disconnected. Your pending edits have
been merged automatically using Yjs CRDT. If anything looks wrong, you can undo
recent changes from the canvas toolbar.
</p>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowConflictResolver(false);
dismissConflict();
}}
className="rounded-lg bg-zinc-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-zinc-600"
>
Got it
</button>
</div>
</div>
</div>
)}
</div>
);
}
193 changes: 193 additions & 0 deletions frontend/src/components/collaboration/ReconnectBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
'use client';

/**
* ReconnectBanner
*
* A user-facing status banner that surfaces three collaboration states:
*
* • disconnected / reconnecting – tells the student their edits are being
* saved locally and will sync once the connection is restored. Includes a
* manual "Reconnect" action.
*
* • conflict detected – lets the student know remote changes
* arrived while they were offline and gives them the option to review the
* diff (via onReviewConflict) or dismiss the notice.
*
* Accessibility:
* – Uses role="status" (polite live region) for non-disruptive announcements
* and role="alert" (assertive) for conflict warnings.
* – Buttons carry descriptive aria-labels so screen-reader users know exactly
* what each action does.
* – The banner is keyboard-focusable via normal tab order.
* – No internal error codes or stack traces are surfaced to the user.
*
* Layout:
* – Positioned at the top of its nearest positioned ancestor so it overlays
* the canvas without pushing content down.
* – Uses responsive flex layout that stacks on narrow viewports.
*/

import type { ReconnectStatus } from '@/hooks/useCollaborativeEditor';

export interface ReconnectBannerProps {
/** Current connection status from useCollaborativeEditor. */
status: ReconnectStatus;
/** Number of local edits buffered while offline. */
pendingUpdateCount: number;
/** Whether a merge conflict was detected on reconnect. */
hasConflict: boolean;
/** Called when the user presses the "Reconnect" button. */
onReconnect: () => void;
/** Called when the user presses "Review changes". */
onReviewConflict: () => void;
/** Called when the user presses "Dismiss" on the conflict banner. */
onDismissConflict: () => void;
}

export function ReconnectBanner({
status,
pendingUpdateCount,
hasConflict,
onReconnect,
onReviewConflict,
onDismissConflict,
}: ReconnectBannerProps) {
const showReconnectBanner = status === 'disconnected' || status === 'reconnecting';

if (!showReconnectBanner && !hasConflict) {
return null;
}

// Conflict banner takes precedence — render it above the reconnect notice.
if (hasConflict) {
return (
<div
role="alert"
aria-live="assertive"
className="flex flex-col items-start gap-2 bg-amber-500/95 px-4 py-3 text-slate-900 shadow-md
sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-start gap-2 sm:items-center">
{/* Warning icon */}
<svg
aria-hidden="true"
className="mt-0.5 h-5 w-5 shrink-0 sm:mt-0"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17
2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10
5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1
0 100-2 1 1 0 000 2z"
clipRule="evenodd"
/>
</svg>
<p className="text-sm font-medium leading-snug">
Remote changes arrived while you were offline.{' '}
<span className="font-semibold">Review your edits</span> before continuing.
</p>
</div>

<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={onReviewConflict}
aria-label="Review conflicting changes"
className="rounded bg-slate-900/15 px-3 py-1.5 text-xs font-semibold
transition-colors hover:bg-slate-900/25 focus:outline-none
focus-visible:ring-2 focus-visible:ring-slate-900"
>
Review changes
</button>
<button
type="button"
onClick={onDismissConflict}
aria-label="Dismiss conflict notice"
className="rounded bg-slate-900/10 px-3 py-1.5 text-xs font-semibold
transition-colors hover:bg-slate-900/20 focus:outline-none
focus-visible:ring-2 focus-visible:ring-slate-900"
>
Dismiss
</button>
</div>
</div>
);
}

// Reconnect / reconnecting banner
const isReconnecting = status === 'reconnecting';

return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="flex flex-col items-start gap-2 bg-rose-600/90 px-4 py-3 text-white shadow-md
sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-start gap-2 sm:items-center">
{isReconnecting ? (
/* Animated spinner */
<svg
aria-hidden="true"
className="mt-0.5 h-5 w-5 shrink-0 animate-spin sm:mt-0"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12" cy="12" r="10"
stroke="currentColor" strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
/* Disconnected icon */
<svg
aria-hidden="true"
className="mt-0.5 h-5 w-5 shrink-0 sm:mt-0"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483
4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clipRule="evenodd"
/>
</svg>
)}

<div>
<p className="text-sm font-semibold leading-tight">
{isReconnecting ? 'Reconnecting…' : 'Collaboration disconnected'}
</p>
<p className="text-xs text-rose-100">
{pendingUpdateCount > 0
? `${pendingUpdateCount} edit${pendingUpdateCount !== 1 ? 's' : ''} saved locally – will sync when reconnected`
: 'Your edits are saved locally and will sync when the connection is restored.'}
</p>
</div>
</div>

{!isReconnecting && (
<button
type="button"
onClick={onReconnect}
aria-label="Manually attempt to reconnect to the collaboration server"
className="shrink-0 rounded bg-white/15 px-3 py-1.5 text-xs font-semibold
transition-colors hover:bg-white/25 focus:outline-none
focus-visible:ring-2 focus-visible:ring-white"
>
Reconnect
</button>
)}
</div>
);
}
Loading