diff --git a/package-lock.json b/package-lock.json index 28458eda..b3c19e7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4243,7 +4243,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4803,7 +4803,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -5736,7 +5736,7 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -6536,7 +6536,7 @@ "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -7141,7 +7141,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7161,7 +7160,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -7264,7 +7262,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -7382,7 +7380,6 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, "license": "MIT" }, "node_modules/scroll-into-view-if-needed": { @@ -7875,7 +7872,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -7898,12 +7895,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -7915,12 +7912,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -7932,12 +7929,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -7949,12 +7946,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -7966,12 +7963,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -7983,12 +7980,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -8000,12 +7997,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8017,12 +8014,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8034,12 +8031,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8051,12 +8048,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8068,12 +8065,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8085,12 +8082,12 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8102,12 +8099,12 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8119,12 +8116,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8136,12 +8133,12 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8153,12 +8150,12 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8170,12 +8167,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -8187,12 +8184,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8204,12 +8201,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8221,12 +8218,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8238,12 +8235,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -8255,12 +8252,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -8272,12 +8269,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -8289,12 +8286,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8306,12 +8303,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8323,12 +8320,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -8337,7 +8334,7 @@ "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/packages/web/app/components/graphql-queue/QueueContext.tsx b/packages/web/app/components/graphql-queue/QueueContext.tsx index 37394009..d6643620 100644 --- a/packages/web/app/components/graphql-queue/QueueContext.tsx +++ b/packages/web/app/components/graphql-queue/QueueContext.tsx @@ -170,9 +170,20 @@ export const GraphQLQueueProvider = ({ parsedParams, children }: GraphQLQueueCon const clientId = isPersistentSessionActive ? persistentSession.clientId : null; const isLeader = isPersistentSessionActive ? persistentSession.isLeader : false; const hasConnected = isPersistentSessionActive ? persistentSession.hasConnected : false; + const isConnecting = isPersistentSessionActive ? persistentSession.isConnecting : false; const users = isPersistentSessionActive ? persistentSession.users : []; const connectionError = isPersistentSessionActive ? persistentSession.error : null; + // Connection readiness: true when we have a session and the connection is ready for actions + // False when connecting, reconnecting, or there's an error + const isConnectionReady = useMemo(() => { + if (!sessionId) return true; // No session = local mode, always ready + if (!backendUrl) return true; // No backend = local mode, always ready + if (!hasConnected) return false; // Still connecting = not ready + if (connectionError) return false; // Has error = not ready + return true; // Connected without error = ready + }, [sessionId, backendUrl, hasConnected, connectionError]); + // Session management functions const startSession = useCallback( async (options?: { discoverable?: boolean; name?: string }) => { @@ -304,6 +315,8 @@ export const GraphQLQueueProvider = ({ parsedParams, children }: GraphQLQueueCon isLeader, isBackendMode: !!backendUrl, hasConnected, + isConnecting, + isConnectionReady, connectionError, disconnect: persistentSession.deactivateSession, @@ -476,6 +489,8 @@ export const GraphQLQueueProvider = ({ parsedParams, children }: GraphQLQueueCon isLeader, users, hasConnected, + isConnecting, + isConnectionReady, connectionError, backendUrl, persistentSession, diff --git a/packages/web/app/components/queue-control/connection-status-banner.tsx b/packages/web/app/components/queue-control/connection-status-banner.tsx new file mode 100644 index 00000000..d1efc499 --- /dev/null +++ b/packages/web/app/components/queue-control/connection-status-banner.tsx @@ -0,0 +1,71 @@ +'use client'; + +import React from 'react'; +import { Alert, Spin } from 'antd'; +import { LoadingOutlined, DisconnectOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; +import { useQueueContext } from '../graphql-queue'; + +interface ConnectionStatusBannerProps { + compact?: boolean; +} + +/** + * Displays a banner when the WebSocket connection exists but isn't ready for actions. + * Shows different states: connecting, error, or disconnected. + */ +export const ConnectionStatusBanner: React.FC = ({ compact = false }) => { + const { sessionId, isConnecting, hasConnected, connectionError, isConnectionReady } = useQueueContext(); + + // Don't show anything if: + // - No session (local mode) + // - Connection is ready + if (!sessionId || isConnectionReady) { + return null; + } + + // Connecting state + if (isConnecting) { + return ( + } size="small" />} + message={compact ? 'Connecting...' : 'Connecting to session'} + description={compact ? undefined : 'Queue changes will sync once connected'} + style={{ marginBottom: compact ? 8 : 16 }} + /> + ); + } + + // Error state + if (connectionError) { + return ( + } + message={compact ? 'Connection error' : 'Connection error'} + description={compact ? undefined : connectionError.message || 'Unable to connect to session'} + style={{ marginBottom: compact ? 8 : 16 }} + /> + ); + } + + // Disconnected state (has session but not connected and not connecting) + if (!hasConnected && !isConnecting) { + return ( + } + message={compact ? 'Disconnected' : 'Disconnected from session'} + description={compact ? undefined : 'Attempting to reconnect...'} + style={{ marginBottom: compact ? 8 : 16 }} + /> + ); + } + + return null; +}; + +export default ConnectionStatusBanner; diff --git a/packages/web/app/components/queue-control/next-climb-button.tsx b/packages/web/app/components/queue-control/next-climb-button.tsx index 4570a381..d70f9d92 100644 --- a/packages/web/app/components/queue-control/next-climb-button.tsx +++ b/packages/web/app/components/queue-control/next-climb-button.tsx @@ -7,6 +7,7 @@ import { BoardRouteParametersWithUuid, BoardDetails } from '@/app/lib/types'; import { FastForwardOutlined } from '@ant-design/icons'; import { track } from '@vercel/analytics'; import Button, { ButtonProps } from 'antd/es/button'; +import { Tooltip } from 'antd'; type NextClimbButtonProps = { navigate: boolean; @@ -18,12 +19,15 @@ const NextButton = (props: ButtonProps) => ( ); export default function NextClimbButton({ navigate = false, boardDetails }: NextClimbButtonProps) { - const { setCurrentClimbQueueItem, getNextClimbQueueItem, viewOnlyMode } = useQueueContext(); // Assuming setSuggestedQueue is available + const { setCurrentClimbQueueItem, getNextClimbQueueItem, viewOnlyMode, isConnectionReady, sessionId } = useQueueContext(); const { board_name, layout_id, size_id, set_ids, angle } = parseBoardRouteParams(useParams()); const nextClimb = getNextClimbQueueItem(); + // Disable when in a session but connection is not ready + const actionsDisabled = sessionId && !isConnectionReady; + const handleClick = () => { if (!nextClimb) { return; @@ -35,7 +39,7 @@ export default function NextClimbButton({ navigate = false, boardDetails }: Next }); }; - if (!viewOnlyMode && navigate && nextClimb) { + if (!viewOnlyMode && !actionsDisabled && navigate && nextClimb) { const climbViewUrl = boardDetails?.layout_name && boardDetails?.size_name && boardDetails?.set_names ? constructClimbViewUrlWithSlugs( @@ -59,5 +63,12 @@ export default function NextClimbButton({ navigate = false, boardDetails }: Next ); } - return ; + + const isDisabled = !nextClimb || viewOnlyMode || !!actionsDisabled; + + return ( + + + + ); } diff --git a/packages/web/app/components/queue-control/previous-climb-button.tsx b/packages/web/app/components/queue-control/previous-climb-button.tsx index 552fd064..f140bde7 100644 --- a/packages/web/app/components/queue-control/previous-climb-button.tsx +++ b/packages/web/app/components/queue-control/previous-climb-button.tsx @@ -8,6 +8,7 @@ import { BoardRouteParametersWithUuid, BoardDetails } from '@/app/lib/types'; import { track } from '@vercel/analytics'; import { FastBackwardOutlined } from '@ant-design/icons'; import Button, { ButtonProps } from 'antd/es/button'; +import { Tooltip } from 'antd'; type PreviousClimbButtonProps = { navigate: boolean; @@ -19,12 +20,15 @@ const PreviousButton = (props: ButtonProps) => ( ); export default function PreviousClimbButton({ navigate = false, boardDetails }: PreviousClimbButtonProps) { - const { getPreviousClimbQueueItem, setCurrentClimbQueueItem, viewOnlyMode } = useQueueContext(); + const { getPreviousClimbQueueItem, setCurrentClimbQueueItem, viewOnlyMode, isConnectionReady, sessionId } = useQueueContext(); const { board_name, layout_id, size_id, set_ids, angle } = parseBoardRouteParams(useParams()); const previousClimb = getPreviousClimbQueueItem(); + // Disable when in a session but connection is not ready + const actionsDisabled = sessionId && !isConnectionReady; + const handleClick = () => { if (previousClimb) { // Remove the next climb from the queue by updating the state @@ -36,7 +40,7 @@ export default function PreviousClimbButton({ navigate = false, boardDetails }: } }; - if (!viewOnlyMode && navigate && previousClimb) { + if (!viewOnlyMode && !actionsDisabled && navigate && previousClimb) { const climbViewUrl = boardDetails?.layout_name && boardDetails?.size_name && boardDetails?.set_names ? constructClimbViewUrlWithSlugs( @@ -60,5 +64,12 @@ export default function PreviousClimbButton({ navigate = false, boardDetails }: ); } - return ; + + const isDisabled = !previousClimb || viewOnlyMode || !!actionsDisabled; + + return ( + + + + ); } diff --git a/packages/web/app/components/queue-control/queue-list-item.tsx b/packages/web/app/components/queue-control/queue-list-item.tsx index 0455f327..edb32b3f 100644 --- a/packages/web/app/components/queue-control/queue-list-item.tsx +++ b/packages/web/app/components/queue-control/queue-list-item.tsx @@ -21,6 +21,7 @@ type QueueListItemProps = { isCurrent: boolean; isHistory: boolean; viewOnlyMode: boolean; + actionsDisabled?: boolean; boardDetails: BoardDetails; setCurrentClimbQueueItem: (item: ClimbQueueItem) => void; onClimbNavigate?: () => void; @@ -79,6 +80,7 @@ const QueueListItem: React.FC = ({ index, isCurrent, isHistory, + actionsDisabled, boardDetails, setCurrentClimbQueueItem, onClimbNavigate, @@ -89,7 +91,8 @@ const QueueListItem: React.FC = ({ useEffect(() => { const element = itemRef.current; - if (element) { + // Don't enable drag-and-drop when actions are disabled + if (element && !actionsDisabled) { return combine( draggable({ element, @@ -119,7 +122,7 @@ const QueueListItem: React.FC = ({ }), ); } - }, [index, item.uuid]); + }, [index, item.uuid, actionsDisabled]); return (
@@ -134,8 +137,8 @@ const QueueListItem: React.FC = ({ : isHistory ? themeTokens.neutral[100] : 'inherit', - opacity: isHistory ? 0.6 : 1, - cursor: 'grab', + opacity: isHistory ? 0.6 : actionsDisabled ? 0.7 : 1, + cursor: actionsDisabled ? 'not-allowed' : 'grab', position: 'relative', WebkitUserSelect: 'none', MozUserSelect: 'none', @@ -143,7 +146,7 @@ const QueueListItem: React.FC = ({ userSelect: 'none', borderLeft: isCurrent ? `3px solid ${themeTokens.colors.primary}` : undefined, }} - onDoubleClick={() => setCurrentClimbQueueItem(item)} + onDoubleClick={() => !actionsDisabled && setCurrentClimbQueueItem(item)} > diff --git a/packages/web/app/components/queue-control/queue-list.tsx b/packages/web/app/components/queue-control/queue-list.tsx index acc6b0bf..da69ab4c 100644 --- a/packages/web/app/components/queue-control/queue-list.tsx +++ b/packages/web/app/components/queue-control/queue-list.tsx @@ -1,6 +1,6 @@ 'use client'; import React, { useEffect } from 'react'; -import { Divider, Row, Col, Button, Flex } from 'antd'; +import { Divider, Row, Col, Button, Flex, Tooltip } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import { useQueueContext } from '../graphql-queue'; import { Climb, BoardDetails } from '@/app/lib/types'; @@ -11,6 +11,7 @@ import QueueListItem from './queue-list-item'; import ClimbThumbnail from '../climb-card/climb-thumbnail'; import ClimbTitle from '../climb-card/climb-title'; import { themeTokens } from '@/app/theme/theme-config'; +import { ConnectionStatusBanner } from './connection-status-banner'; type QueueListProps = { boardDetails: BoardDetails; @@ -26,8 +27,13 @@ const QueueList: React.FC = ({ boardDetails, onClimbNavigate }) setCurrentClimbQueueItem, setQueue, addToQueue, + isConnectionReady, + sessionId, } = useQueueContext(); + // Disable actions when in a session but connection is not ready + const actionsDisabled = sessionId && !isConnectionReady; + // Monitor for drag-and-drop events useEffect(() => { const cleanup = monitorForElements({ @@ -67,6 +73,7 @@ const QueueList: React.FC = ({ boardDetails, onClimbNavigate }) return ( <> + {queue.map((climbQueueItem, index) => { const isCurrent = currentClimbQueueItem?.uuid === climbQueueItem.uuid; @@ -82,6 +89,7 @@ const QueueList: React.FC = ({ boardDetails, onClimbNavigate }) isCurrent={isCurrent} isHistory={isHistory} viewOnlyMode={viewOnlyMode} + actionsDisabled={!!actionsDisabled} boardDetails={boardDetails} setCurrentClimbQueueItem={setCurrentClimbQueueItem} onClimbNavigate={onClimbNavigate} @@ -119,7 +127,14 @@ const QueueList: React.FC = ({ boardDetails, onClimbNavigate }) -
diff --git a/packages/web/app/components/queue-control/types.ts b/packages/web/app/components/queue-control/types.ts index f9e56029..1560ecef 100644 --- a/packages/web/app/components/queue-control/types.ts +++ b/packages/web/app/components/queue-control/types.ts @@ -66,6 +66,8 @@ export interface QueueContextType { isLeader?: boolean; isBackendMode?: boolean; hasConnected?: boolean; + isConnecting?: boolean; + isConnectionReady?: boolean; connectionError?: Error | null; disconnect?: () => void; addToQueue: (climb: Climb) => void;