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
108 changes: 108 additions & 0 deletions packages/web/src/components/project/reconcile-tab/PresenceAvatars.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* PresenceAvatars - Displays stacked avatars of users viewing the reconciliation
*
* Features:
* - Stacked avatar display with overlap
* - Tooltip showing user name and current question
* - Click to jump to user's current question
* - Overflow indicator for many users
*/

import { For, Show } from 'solid-js';
import { Avatar, AvatarImage, AvatarFallback, getInitials } from '@/components/ui/avatar';
import {
Tooltip,
TooltipTrigger,
TooltipPositioner,
TooltipContent,
} from '@/components/ui/tooltip';
import { API_BASE } from '@config/api.js';

/**
* @param {Object} props
* @param {Array} props.users - Array of presence users with { userId, name, image, currentPage, color }
* @param {Function} props.onUserClick - Callback when user avatar is clicked (receives userId, currentPage)
* @param {number} [props.maxVisible=4] - Maximum number of avatars to show before overflow
* @param {Function} [props.getPageLabel] - Function to convert page index to display label (default: "Question N")
*/
export default function PresenceAvatars(props) {
const maxVisible = () => props.maxVisible ?? 4;
const visibleUsers = () => props.users.slice(0, maxVisible());
const overflowCount = () => Math.max(0, props.users.length - maxVisible());

const getPageLabel = pageIndex => {
if (props.getPageLabel) {
return props.getPageLabel(pageIndex);
}
return `Question ${pageIndex + 1}`;
};

// Build avatar URL from user data
const getAvatarUrl = user => {
if (user.image) {
// If it's a full URL, use it directly
if (user.image.startsWith('http')) {
return user.image;
}
// Otherwise, it's a path to our API
return `${API_BASE}/api/users/avatar/${user.userId}`;
}
return null;
};

return (
<div class='flex items-center gap-2'>
<div class='flex -space-x-2'>
<For each={visibleUsers()}>
{user => (
<Tooltip openDelay={200} positioning={{ placement: 'bottom' }}>
<TooltipTrigger>
<button
onClick={() => props.onUserClick?.(user.userId, user.currentPage)}
class='focus:ring-primary relative rounded-full transition-transform hover:z-10 hover:scale-110 focus:z-10 focus:ring-2 focus:ring-offset-2 focus:outline-none'
style={{
'box-shadow': `0 0 0 2px ${user.color.hex}`,
}}
>
<Avatar class='h-7 w-7 border-2 border-white text-xs'>
<AvatarImage src={getAvatarUrl(user)} alt={user.name} />
<AvatarFallback
class={`${user.color.bg} text-white`}
style={{ 'background-color': user.color.hex }}
>
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
</button>
</TooltipTrigger>
<TooltipPositioner>
<TooltipContent class='flex flex-col gap-0.5'>
<span class='font-medium'>{user.name}</span>
<span class='text-muted-foreground text-xs'>
Viewing {getPageLabel(user.currentPage)}
</span>
</TooltipContent>
</TooltipPositioner>
</Tooltip>
)}
</For>

{/* Overflow indicator */}
<Show when={overflowCount() > 0}>
<Tooltip openDelay={200} positioning={{ placement: 'bottom' }}>
<TooltipTrigger>
<div class='bg-muted text-muted-foreground flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-xs font-medium'>
+{overflowCount()}
</div>
</TooltipTrigger>
<TooltipPositioner>
<TooltipContent>
{overflowCount()} more {overflowCount() === 1 ? 'person' : 'people'} viewing
</TooltipContent>
</TooltipPositioner>
</Tooltip>
</Show>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* QuestionPresenceIndicator - Colored rings on question pills showing who's viewing
*
* Features:
* - Renders colored ring(s) around the parent element
* - Supports multiple users with stacked rings
* - Pulsing animation for visibility
*/

import { For, Show } from 'solid-js';

/**
* @param {Object} props
* @param {Array} props.users - Array of users viewing this question with { userId, color, name }
* @param {number} [props.maxRings=2] - Maximum number of rings to show
* @param {string} [props.size='md'] - Ring size: 'sm', 'md', 'lg'
*/
export default function QuestionPresenceIndicator(props) {
const users = () => props.users ?? [];
const maxRings = () => props.maxRings ?? 2;
const visibleUsers = () => users().slice(0, maxRings());

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Ring offsets for stacking effect
const getOffset = index => {
const offsets = {
sm: 2,
md: 3,
lg: 4,
};
const base = offsets[props.size || 'md'] || 3;
return index * base;
};

// Ring width - thicker for better visibility
const getRingWidth = () => {
const widths = {
sm: 2,
md: 3,
lg: 4,
};
return widths[props.size || 'md'] || 3;
};

return (
<Show when={users().length > 0}>
<div class='pointer-events-none absolute inset-0'>
<For each={visibleUsers()}>
{(user, index) => (
<div
class='absolute inset-0 animate-pulse rounded-full'
style={{
margin: `-${getOffset(index())}px`,
border: `${getRingWidth()}px solid ${user.color.hex}`,
opacity: 0.85 - index() * 0.15,
'box-shadow': `0 0 8px ${user.color.hex}50`,
}}
/>
)}
</For>
</div>
</Show>
);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
/**
* ReconciliationWithPdf - Wrapper that combines ChecklistReconciliation with a PDF viewer
* in a split-screen layout. The PDF is read-only during reconciliation.
*
* Includes presence features:
* - Shows avatars of other users viewing the reconciliation
* - Shows colored rings on question pills indicating viewer positions
* - Shows remote mouse cursors in real-time
*/

import { Show, createMemo, lazy, Suspense } from 'solid-js';
import { Show, createMemo, createSignal, lazy, Suspense } from 'solid-js';
import { createStore } from 'solid-js/store';
import { FiArrowLeft } from 'solid-icons/fi';
import ChecklistReconciliation from './amstar2-reconcile/ChecklistReconciliation.jsx';

import Navbar from './amstar2-reconcile/Navbar.jsx';
import SplitScreenLayout from '@/components/checklist/SplitScreenLayout.jsx';
import { useReconciliationPresence } from '@primitives/useReconciliationPresence.js';
import PresenceAvatars from './PresenceAvatars.jsx';
import RemoteCursors from './RemoteCursors.jsx';

const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx'));

Expand All @@ -33,9 +40,16 @@ const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx'));
* @param {Function} props.onPdfSelect - Handler for PDF selection change
* @param {Function} props.getQuestionNote - Function to get Y.Text for a question note (questionKey => Y.Text)
* @param {Function} props.updateChecklistAnswer - Function to update a question answer (questionKey, questionData)
* @param {Function} props.getAwareness - Function to get Yjs awareness instance
* @param {Function} props.currentUser - Reactive getter for current user { id, name, image }
* @param {string} [props.checklistType='AMSTAR2'] - Checklist type for presence filtering
* @returns {JSX.Element}
*/
export default function ReconciliationWithPdf(props) {
// Container ref for mouse cursor tracking
let containerRef;
const [containerScrollY, setContainerScrollY] = createSignal(0);

// Navbar store for deep reactivity - ChecklistReconciliation will update this
const [navbarStore, setNavbarStore] = createStore({
questionKeys: [],
Expand All @@ -51,10 +65,24 @@ export default function ReconciliationWithPdf(props) {
onReset: null,
});

// Presence tracking
const presence = useReconciliationPresence({
getAwareness: () => props.getAwareness?.(),
getCurrentPage: () => navbarStore.currentPage,
checklistType: () => props.checklistType || 'AMSTAR2',
currentUser: () => props.currentUser?.(),
containerRef: () => containerRef,
});

// Handle scroll updates for cursor position adjustment
function handleScroll(event) {
setContainerScrollY(event.target.scrollTop);
}

// Check if we have PDF to show (reactive)
const hasPdf = createMemo(() => !!(props.pdfData || props.pdfLoading));

// Build header content with back button, title, and navbar
// Build header content with back button, title, presence avatars, and navbar
const headerContent = (
<>
{/* Back button */}
Expand All @@ -76,10 +104,22 @@ export default function ReconciliationWithPdf(props) {

<div class='bg-border h-8 w-px shrink-0' />

{/* Presence avatars - show other users viewing this reconciliation */}
<Show when={presence.remoteUsers().length > 0}>
<PresenceAvatars
users={presence.remoteUsers()}
onUserClick={(userId, currentPage) => {
navbarStore.goToQuestion?.(currentPage);
}}
getPageLabel={pageIndex => `Question ${pageIndex + 1}`}
/>
<div class='bg-border h-8 w-px shrink-0' />
</Show>

{/* Navbar - question navigation pills */}
<Show when={navbarStore.questionKeys.length > 0}>
<div class='flex flex-1 items-center gap-4 overflow-x-auto'>
<Navbar store={navbarStore} />
<Navbar store={navbarStore} usersByPage={presence.usersByPage()} />
</div>
</Show>
</>
Expand All @@ -96,22 +136,30 @@ export default function ReconciliationWithPdf(props) {
pdfUrl={props.pdfUrl}
pdfData={props.pdfData}
>
{/* First panel: Reconciliation view */}
<ChecklistReconciliation
checklist1={props.checklist1}
checklist2={props.checklist2}
reconciledChecklist={props.reconciledChecklist}
reconciledChecklistId={props.reconciledChecklistId}
reviewer1Name={props.reviewer1Name}
reviewer2Name={props.reviewer2Name}
onSaveReconciled={props.onSaveReconciled}
onCancel={props.onCancel}
setNavbarStore={setNavbarStore}
getQuestionNote={props.getQuestionNote}
updateChecklistAnswer={(questionKey, questionData) =>
props.updateChecklistAnswer?.(questionKey, questionData)
}
/>
{/* First panel: Reconciliation view with cursor tracking */}
<div ref={containerRef} class='relative h-full overflow-auto' onScroll={handleScroll}>
{/* Remote cursors overlay */}
<RemoteCursors
users={presence.usersWithCursors()}
containerScrollY={containerScrollY()}
/>

<ChecklistReconciliation
checklist1={props.checklist1}
checklist2={props.checklist2}
reconciledChecklist={props.reconciledChecklist}
reconciledChecklistId={props.reconciledChecklistId}
reviewer1Name={props.reviewer1Name}
reviewer2Name={props.reviewer2Name}
onSaveReconciled={props.onSaveReconciled}
onCancel={props.onCancel}
setNavbarStore={setNavbarStore}
getQuestionNote={props.getQuestionNote}
updateChecklistAnswer={(questionKey, questionData) =>
props.updateChecklistAnswer?.(questionKey, questionData)
}
/>
</div>

{/* Second panel: PDF Viewer (read-only) - only rendered when PDF exists */}
<Show when={hasPdf}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createSignal, createMemo, createEffect, Show } from 'solid-js';
import { useParams, useNavigate } from '@solidjs/router';
import { useProjectContext } from '@/components/project/ProjectContext.jsx';
import projectStore from '@/stores/projectStore.js';
import { useBetterAuth } from '@api/better-auth-store.js';
import projectActionsStore from '@/stores/projectActionsStore';
import { ACCESS_DENIED_ERRORS } from '@/constants/errors.js';
import { CHECKLIST_STATUS } from '@/constants/checklist-status.js';
Expand Down Expand Up @@ -39,6 +40,9 @@ export default function ReconciliationWrapper() {

const [error, setError] = createSignal(null);

// Get current user for presence
const { user } = useBetterAuth();

// Destructure Y.js operations from parent ProjectView's connection
const {
createChecklist: createProjectChecklist,
Expand All @@ -50,8 +54,20 @@ export default function ReconciliationWrapper() {
getRobinsText,
getRob2Text,
saveReconciliationProgress,
getAwareness,
} = projectOps || {};

// Current user for presence features
const currentUser = createMemo(() => {
const u = user();
if (!u) return null;
return {
id: u.id,
name: u.name || u.email || 'Unknown',
image: u.image,
};
});

// Set active project for action store
createEffect(() => {
const pid = params.projectId;
Expand Down Expand Up @@ -494,6 +510,9 @@ export default function ReconciliationWrapper() {
if (!id) return;
updateChecklistAnswer(params.studyId, id, questionKey, questionData);
}}
getAwareness={getAwareness}
currentUser={currentUser}
checklistType='AMSTAR2'
/>
}
>
Expand Down Expand Up @@ -527,6 +546,9 @@ export default function ReconciliationWrapper() {
questionKey,
)
}
getAwareness={getAwareness}
currentUser={currentUser}
checklistType='ROB2'
/>
</Show>
}
Expand Down Expand Up @@ -561,6 +583,9 @@ export default function ReconciliationWrapper() {
questionKey,
)
}
getAwareness={getAwareness}
currentUser={currentUser}
checklistType='ROBINS_I'
/>
</Show>
</Show>
Expand Down
Loading