Skip to content
Open
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
69 changes: 33 additions & 36 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions packages/web/app/components/graphql-queue/QueueContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -304,6 +315,8 @@ export const GraphQLQueueProvider = ({ parsedParams, children }: GraphQLQueueCon
isLeader,
isBackendMode: !!backendUrl,
hasConnected,
isConnecting,
isConnectionReady,
connectionError,
disconnect: persistentSession.deactivateSession,

Expand Down Expand Up @@ -476,6 +489,8 @@ export const GraphQLQueueProvider = ({ parsedParams, children }: GraphQLQueueCon
isLeader,
users,
hasConnected,
isConnecting,
isConnectionReady,
connectionError,
backendUrl,
persistentSession,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConnectionStatusBannerProps> = ({ 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 (
<Alert
type="warning"
showIcon
icon={<Spin indicator={<LoadingOutlined spin />} 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 (
<Alert
type="error"
showIcon
icon={<ExclamationCircleOutlined />}
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 (
<Alert
type="warning"
showIcon
icon={<DisconnectOutlined />}
message={compact ? 'Disconnected' : 'Disconnected from session'}
description={compact ? undefined : 'Attempting to reconnect...'}
style={{ marginBottom: compact ? 8 : 16 }}
/>
);
}

return null;
};

export default ConnectionStatusBanner;
17 changes: 14 additions & 3 deletions packages/web/app/components/queue-control/next-climb-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<BoardRouteParametersWithUuid>());

const nextClimb = getNextClimbQueueItem();

// Disable when in a session but connection is not ready
const actionsDisabled = sessionId && !isConnectionReady;

const handleClick = () => {
if (!nextClimb) {
return;
Expand All @@ -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(
Expand All @@ -59,5 +63,12 @@ export default function NextClimbButton({ navigate = false, boardDetails }: Next
</Link>
);
}
return <NextButton onClick={handleClick} disabled={!nextClimb || viewOnlyMode} />;

const isDisabled = !nextClimb || viewOnlyMode || !!actionsDisabled;

return (
<Tooltip title={actionsDisabled ? 'Waiting for connection...' : undefined}>
<NextButton onClick={handleClick} disabled={isDisabled} />
</Tooltip>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<BoardRouteParametersWithUuid>());

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
Expand All @@ -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(
Expand All @@ -60,5 +64,12 @@ export default function PreviousClimbButton({ navigate = false, boardDetails }:
</Link>
);
}
return <PreviousButton onClick={handleClick} disabled={!previousClimb || viewOnlyMode} />;

const isDisabled = !previousClimb || viewOnlyMode || !!actionsDisabled;

return (
<Tooltip title={actionsDisabled ? 'Waiting for connection...' : undefined}>
<PreviousButton onClick={handleClick} disabled={isDisabled} />
</Tooltip>
);
}
13 changes: 8 additions & 5 deletions packages/web/app/components/queue-control/queue-list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type QueueListItemProps = {
isCurrent: boolean;
isHistory: boolean;
viewOnlyMode: boolean;
actionsDisabled?: boolean;
boardDetails: BoardDetails;
setCurrentClimbQueueItem: (item: ClimbQueueItem) => void;
onClimbNavigate?: () => void;
Expand Down Expand Up @@ -79,6 +80,7 @@ const QueueListItem: React.FC<QueueListItemProps> = ({
index,
isCurrent,
isHistory,
actionsDisabled,
boardDetails,
setCurrentClimbQueueItem,
onClimbNavigate,
Expand All @@ -89,7 +91,8 @@ const QueueListItem: React.FC<QueueListItemProps> = ({
useEffect(() => {
const element = itemRef.current;

if (element) {
// Don't enable drag-and-drop when actions are disabled
if (element && !actionsDisabled) {
return combine(
draggable({
element,
Expand Down Expand Up @@ -119,7 +122,7 @@ const QueueListItem: React.FC<QueueListItemProps> = ({
}),
);
}
}, [index, item.uuid]);
}, [index, item.uuid, actionsDisabled]);

return (
<div ref={itemRef}>
Expand All @@ -134,16 +137,16 @@ const QueueListItem: React.FC<QueueListItemProps> = ({
: 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',
msUserSelect: 'none',
userSelect: 'none',
borderLeft: isCurrent ? `3px solid ${themeTokens.colors.primary}` : undefined,
}}
onDoubleClick={() => setCurrentClimbQueueItem(item)}
onDoubleClick={() => !actionsDisabled && setCurrentClimbQueueItem(item)}
>
<Row style={{ width: '100%' }} gutter={[8, 8]} align="middle" wrap={false}>
<Col xs={2} sm={1}>
Expand Down
19 changes: 17 additions & 2 deletions packages/web/app/components/queue-control/queue-list.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -26,8 +27,13 @@ const QueueList: React.FC<QueueListProps> = ({ 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({
Expand Down Expand Up @@ -67,6 +73,7 @@ const QueueList: React.FC<QueueListProps> = ({ boardDetails, onClimbNavigate })

return (
<>
<ConnectionStatusBanner compact />
<Flex vertical>
{queue.map((climbQueueItem, index) => {
const isCurrent = currentClimbQueueItem?.uuid === climbQueueItem.uuid;
Expand All @@ -82,6 +89,7 @@ const QueueList: React.FC<QueueListProps> = ({ boardDetails, onClimbNavigate })
isCurrent={isCurrent}
isHistory={isHistory}
viewOnlyMode={viewOnlyMode}
actionsDisabled={!!actionsDisabled}
boardDetails={boardDetails}
setCurrentClimbQueueItem={setCurrentClimbQueueItem}
onClimbNavigate={onClimbNavigate}
Expand Down Expand Up @@ -119,7 +127,14 @@ const QueueList: React.FC<QueueListProps> = ({ boardDetails, onClimbNavigate })
<ClimbTitle climb={climb} showAngle centered />
</Col>
<Col xs={3} sm={2}>
<Button type="default" icon={<PlusOutlined />} onClick={() => addToQueue(climb)} />
<Tooltip title={actionsDisabled ? 'Waiting for connection...' : undefined}>
<Button
type="default"
icon={<PlusOutlined />}
onClick={() => addToQueue(climb)}
disabled={!!actionsDisabled}
/>
</Tooltip>
</Col>
</Row>
</div>
Expand Down
2 changes: 2 additions & 0 deletions packages/web/app/components/queue-control/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down