feat: refactor session components to utilize BaseSessionLiveWrapper, streamlining sharing functionality and reducing code duplication across various session types
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m14s

This commit is contained in:
Julien Froidefond
2026-02-18 08:39:15 +01:00
parent 35228441e3
commit 739b0bf87d
18 changed files with 381 additions and 795 deletions

View File

@@ -27,24 +27,14 @@ import {
} from '@/lib/workshops';
import { useClickOutside } from '@/hooks/useClickOutside';
import type { Share } from '@/lib/share-utils';
const TYPE_TABS = [
{ value: 'all' as const, icon: '📋', label: 'Tous' },
{ value: 'team' as const, icon: '🏢', label: 'Équipe' },
...WORKSHOPS.map((w) => ({ value: w.id, icon: w.icon, label: w.labelShort })),
];
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: 'VIEWER' | 'EDITOR';
user: ShareUser;
}
interface ResolvedCollaborator {
raw: string;
matchedUser: {

View File

@@ -0,0 +1,100 @@
'use client';
import { useState, useCallback } from 'react';
import { useLive, type LiveEvent } from '@/hooks/useLive';
import { CollaborationToolbar } from './CollaborationToolbar';
import { ShareModal } from './ShareModal';
import type { ShareRole } from '@prisma/client';
import type { TeamWithMembers, Share } from '@/lib/share-utils';
export type LiveApiPath = 'sessions' | 'motivators' | 'weather' | 'year-review' | 'weekly-checkin';
interface ShareModalConfig {
title: string;
sessionSubtitle: string;
helpText: React.ReactNode;
}
interface BaseSessionLiveWrapperConfig {
apiPath: LiveApiPath;
shareModal: ShareModalConfig;
onShareWithEmail: (email: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
onRemoveShare: (userId: string) => Promise<unknown>;
onShareWithTeam?: (teamId: string, role: ShareRole) => Promise<{ success: boolean; error?: string }>;
}
interface BaseSessionLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode;
config: BaseSessionLiveWrapperConfig;
}
export function BaseSessionLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
userTeams = [],
children,
config,
}: BaseSessionLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: LiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useLive({
sessionId,
apiPath: config.apiPath,
currentUserId,
onEvent: handleEvent,
});
return (
<>
<CollaborationToolbar
isConnected={isConnected}
error={error}
lastEventUser={lastEventUser}
canEdit={canEdit}
shares={shares}
onShareClick={() => setShareModalOpen(true)}
/>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
title={config.shareModal.title}
sessionSubtitle={config.shareModal.sessionSubtitle}
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={config.onShareWithEmail}
onShareWithTeam={config.onShareWithTeam}
onRemoveShare={config.onRemoveShare}
helpText={config.shareModal.helpText}
/>
</>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { LiveIndicator } from './LiveIndicator';
import { ShareButton } from './ShareButton';
import { CollaboratorAvatars } from './CollaboratorAvatars';
import type { Share } from '@/lib/share-utils';
interface CollaborationToolbarProps {
isConnected: boolean;
error: string | null;
lastEventUser: string | null;
canEdit: boolean;
shares: Share[];
onShareClick: () => void;
}
export function CollaborationToolbar({
isConnected,
error,
lastEventUser,
canEdit,
shares,
onShareClick,
}: CollaborationToolbarProps) {
return (
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<CollaboratorAvatars shares={shares} />
<ShareButton onClick={onShareClick} />
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { Avatar } from '@/components/ui/Avatar';
import type { Share } from '@/lib/share-utils';
interface CollaboratorAvatarsProps {
shares: Share[];
maxVisible?: number;
}
export function CollaboratorAvatars({ shares, maxVisible = 3 }: CollaboratorAvatarsProps) {
if (shares.length === 0) {
return null;
}
const visibleShares = shares.slice(0, maxVisible);
const remainingCount = shares.length - maxVisible;
return (
<div className="flex -space-x-2">
{visibleShares.map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{remainingCount > 0 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{remainingCount}
</div>
)}
</div>
);
}

View File

@@ -1,27 +1,8 @@
'use client';
import { useState, useCallback } from 'react';
import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive';
import { LiveIndicator } from './LiveIndicator';
import { ShareModal } from './ShareModal';
import { BaseSessionLiveWrapper } from './BaseSessionLiveWrapper';
import { shareSessionAction, removeShareAction } from '@/actions/share';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
import type { TeamWithMembers } from '@/lib/share-utils';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
import type { TeamWithMembers, Share } from '@/lib/share-utils';
interface SessionLiveWrapperProps {
sessionId: string;
@@ -44,105 +25,33 @@ export function SessionLiveWrapper({
userTeams = [],
children,
}: SessionLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: LiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useSessionLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
title="Partager la session"
sessionSubtitle="Session"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareSessionAction(sessionId, email, role)}
onRemoveShare={(userId) => removeShareAction(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier les items et actions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
<BaseSessionLiveWrapper
sessionId={sessionId}
sessionTitle={sessionTitle}
currentUserId={currentUserId}
shares={shares}
isOwner={isOwner}
canEdit={canEdit}
userTeams={userTeams}
config={{
apiPath: 'sessions',
shareModal: {
title: 'Partager la session',
sessionSubtitle: 'Session',
helpText: (
<>
<strong>Éditeur</strong> : peut modifier les items et actions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
),
},
onShareWithEmail: (email, role) => shareSessionAction(sessionId, email, role),
onRemoveShare: (userId) => removeShareAction(sessionId, userId),
}}
>
{children}
</BaseSessionLiveWrapper>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { Button } from '@/components/ui/Button';
interface ShareButtonProps {
onClick: () => void;
}
export function ShareButton({ onClick }: ShareButtonProps) {
return (
<Button variant="outline" size="sm" onClick={onClick}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
);
}

View File

@@ -8,22 +8,9 @@ import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Avatar } from '@/components/ui/Avatar';
import { Select } from '@/components/ui/Select';
import { getTeamMembersForShare, type TeamWithMembers } from '@/lib/share-utils';
import { getTeamMembersForShare, type TeamWithMembers, type Share } from '@/lib/share-utils';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt?: Date;
}
type ShareTab = 'teamMember' | 'team' | 'email';
interface ShareModalProps {

View File

@@ -1,3 +1,8 @@
export { LiveIndicator } from './LiveIndicator';
export { ShareModal } from './ShareModal';
export { SessionLiveWrapper } from './SessionLiveWrapper';
export { BaseSessionLiveWrapper } from './BaseSessionLiveWrapper';
export { ShareButton } from './ShareButton';
export { CollaboratorAvatars } from './CollaboratorAvatars';
export { CollaborationToolbar } from './CollaborationToolbar';
export type { LiveApiPath } from './BaseSessionLiveWrapper';

View File

@@ -1,27 +1,8 @@
'use client';
import { useState, useCallback } from 'react';
import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { ShareModal } from '@/components/collaboration/ShareModal';
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
import { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
import type { TeamWithMembers } from '@/lib/share-utils';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
import type { TeamWithMembers, Share } from '@/lib/share-utils';
interface MotivatorLiveWrapperProps {
sessionId: string;
@@ -44,105 +25,33 @@ export function MotivatorLiveWrapper({
userTeams = [],
children,
}: MotivatorLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: MotivatorLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useMotivatorLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
title="Partager la session"
sessionSubtitle="Session Moving Motivators"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareMotivatorSession(sessionId, email, role)}
onRemoveShare={(userId) => removeMotivatorShare(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
<BaseSessionLiveWrapper
sessionId={sessionId}
sessionTitle={sessionTitle}
currentUserId={currentUserId}
shares={shares}
isOwner={isOwner}
canEdit={canEdit}
userTeams={userTeams}
config={{
apiPath: 'motivators',
shareModal: {
title: 'Partager la session',
sessionSubtitle: 'Session Moving Motivators',
helpText: (
<>
<strong>Éditeur</strong> : peut modifier les cartes et leurs positions
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
),
},
onShareWithEmail: (email, role) => shareMotivatorSession(sessionId, email, role),
onRemoveShare: (userId) => removeMotivatorShare(sessionId, userId),
}}
>
{children}
</BaseSessionLiveWrapper>
);
}

View File

@@ -1,33 +1,8 @@
'use client';
import { useState, useCallback } from 'react';
import { useWeatherLive, type WeatherLiveEvent } from '@/hooks/useWeatherLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { ShareModal } from '@/components/collaboration/ShareModal';
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
import { shareWeatherSession, shareWeatherSessionToTeam, removeWeatherShare } from '@/actions/weather';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
interface Team {
id: string;
name: string;
description: string | null;
userRole: 'ADMIN' | 'MEMBER';
}
import type { TeamWithMembers, Share } from '@/lib/share-utils';
interface WeatherLiveWrapperProps {
sessionId: string;
@@ -36,7 +11,7 @@ interface WeatherLiveWrapperProps {
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: Team[];
userTeams?: TeamWithMembers[];
children: React.ReactNode;
}
@@ -50,106 +25,34 @@ export function WeatherLiveWrapper({
userTeams = [],
children,
}: WeatherLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: WeatherLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useWeatherLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
title="Partager la météo"
sessionSubtitle="Météo personnelle"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareWeatherSession(sessionId, email, role)}
onShareWithTeam={(teamId, role) => shareWeatherSessionToTeam(sessionId, teamId, role)}
onRemoveShare={(userId) => removeWeatherShare(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier sa météo et voir celle des autres
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
<BaseSessionLiveWrapper
sessionId={sessionId}
sessionTitle={sessionTitle}
currentUserId={currentUserId}
shares={shares}
isOwner={isOwner}
canEdit={canEdit}
userTeams={userTeams}
config={{
apiPath: 'weather',
shareModal: {
title: 'Partager la météo',
sessionSubtitle: 'Météo personnelle',
helpText: (
<>
<strong>Éditeur</strong> : peut modifier sa météo et voir celle des autres
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
),
},
onShareWithEmail: (email, role) => shareWeatherSession(sessionId, email, role),
onShareWithTeam: (teamId, role) => shareWeatherSessionToTeam(sessionId, teamId, role),
onRemoveShare: (userId) => removeWeatherShare(sessionId, userId),
}}
>
{children}
</BaseSessionLiveWrapper>
);
}

View File

@@ -1,27 +1,8 @@
'use client';
import { useState, useCallback } from 'react';
import { useWeeklyCheckInLive, type WeeklyCheckInLiveEvent } from '@/hooks/useWeeklyCheckInLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { ShareModal } from '@/components/collaboration/ShareModal';
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
import { shareWeeklyCheckInSession, removeWeeklyCheckInShare } from '@/actions/weekly-checkin';
import type { TeamWithMembers } from '@/lib/share-utils';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
import type { TeamWithMembers, Share } from '@/lib/share-utils';
interface WeeklyCheckInLiveWrapperProps {
sessionId: string;
@@ -44,105 +25,33 @@ export function WeeklyCheckInLiveWrapper({
userTeams = [],
children,
}: WeeklyCheckInLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: WeeklyCheckInLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useWeeklyCheckInLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
title="Options de partage"
sessionSubtitle="Check-in hebdomadaire"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareWeeklyCheckInSession(sessionId, email, role)}
onRemoveShare={(userId) => removeWeeklyCheckInShare(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
<BaseSessionLiveWrapper
sessionId={sessionId}
sessionTitle={sessionTitle}
currentUserId={currentUserId}
shares={shares}
isOwner={isOwner}
canEdit={canEdit}
userTeams={userTeams}
config={{
apiPath: 'weekly-checkin',
shareModal: {
title: 'Options de partage',
sessionSubtitle: 'Check-in hebdomadaire',
helpText: (
<>
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
),
},
onShareWithEmail: (email, role) => shareWeeklyCheckInSession(sessionId, email, role),
onRemoveShare: (userId) => removeWeeklyCheckInShare(sessionId, userId),
}}
>
{children}
</BaseSessionLiveWrapper>
);
}

View File

@@ -1,27 +1,8 @@
'use client';
import { useState, useCallback } from 'react';
import { useYearReviewLive, type YearReviewLiveEvent } from '@/hooks/useYearReviewLive';
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
import { ShareModal } from '@/components/collaboration/ShareModal';
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
import { shareYearReviewSession, removeYearReviewShare } from '@/actions/year-review';
import type { TeamWithMembers } from '@/lib/share-utils';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
import type { ShareRole } from '@prisma/client';
interface ShareUser {
id: string;
name: string | null;
email: string;
}
interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt: Date;
}
import type { TeamWithMembers, Share } from '@/lib/share-utils';
interface YearReviewLiveWrapperProps {
sessionId: string;
@@ -44,106 +25,34 @@ export function YearReviewLiveWrapper({
userTeams = [],
children,
}: YearReviewLiveWrapperProps) {
const [shareModalOpen, setShareModalOpen] = useState(false);
const [lastEventUser, setLastEventUser] = useState<string | null>(null);
const handleEvent = useCallback((event: YearReviewLiveEvent) => {
// Show who made the last change
if (event.user?.name || event.user?.email) {
setLastEventUser(event.user.name || event.user.email);
// Clear after 3 seconds
setTimeout(() => setLastEventUser(null), 3000);
}
}, []);
const { isConnected, error } = useYearReviewLive({
sessionId,
currentUserId,
onEvent: handleEvent,
});
return (
<>
{/* Header toolbar */}
<div className="mb-4 flex items-center justify-between rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-4">
<LiveIndicator isConnected={isConnected} error={error} />
{lastEventUser && (
<div className="flex items-center gap-2 text-sm text-muted animate-pulse">
<span></span>
<span>{lastEventUser} édite...</span>
</div>
)}
{!canEdit && (
<div className="flex items-center gap-2 rounded-full bg-yellow/10 px-3 py-1.5 text-sm text-yellow">
<span>👁</span>
<span>Mode lecture</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{/* Collaborators avatars */}
{shares.length > 0 && (
<div className="flex -space-x-2">
{shares.slice(0, 3).map((share) => (
<Avatar
key={share.id}
email={share.user.email}
name={share.user.name}
size={32}
className="border-2 border-card"
/>
))}
{shares.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-muted/20 text-xs font-medium text-muted">
+{shares.length - 3}
</div>
)}
</div>
)}
<Button variant="outline" size="sm" onClick={() => setShareModalOpen(true)}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="mr-2 h-4 w-4"
>
<path d="M13 4.5a2.5 2.5 0 11.702 1.737L6.97 9.604a2.518 2.518 0 010 .792l6.733 3.367a2.5 2.5 0 11-.671 1.341l-6.733-3.367a2.5 2.5 0 110-3.475l6.733-3.366A2.52 2.52 0 0113 4.5z" />
</svg>
Partager
</Button>
</div>
</div>
{/* Content */}
<div className={!canEdit ? 'pointer-events-none opacity-90' : ''}>{children}</div>
{/* Share Modal */}
<ShareModal
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
title="Partager le bilan"
sessionSubtitle="Bilan annuel"
sessionTitle={sessionTitle}
shares={shares}
isOwner={isOwner}
userTeams={userTeams}
currentUserId={currentUserId}
onShareWithEmail={(email, role) => shareYearReviewSession(sessionId, email, role)}
onRemoveShare={(userId) => removeYearReviewShare(sessionId, userId)}
helpText={
<>
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
}
/>
</>
<BaseSessionLiveWrapper
sessionId={sessionId}
sessionTitle={sessionTitle}
currentUserId={currentUserId}
shares={shares}
isOwner={isOwner}
canEdit={canEdit}
userTeams={userTeams}
config={{
apiPath: 'year-review',
shareModal: {
title: 'Partager le bilan',
sessionSubtitle: 'Bilan annuel',
helpText: (
<>
<strong>Éditeur</strong> : peut modifier les items et leurs catégories
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
),
},
onShareWithEmail: (email, role) => shareYearReviewSession(sessionId, email, role),
onRemoveShare: (userId) => removeYearReviewShare(sessionId, userId),
}}
>
{children}
</BaseSessionLiveWrapper>
);
}

View File

@@ -1,33 +0,0 @@
'use client';
import { useLive, type LiveEvent } from './useLive';
export type MotivatorLiveEvent = LiveEvent;
interface UseMotivatorLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: MotivatorLiveEvent) => void;
}
interface UseMotivatorLiveReturn {
isConnected: boolean;
lastEvent: MotivatorLiveEvent | null;
error: string | null;
}
export function useMotivatorLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseMotivatorLiveOptions): UseMotivatorLiveReturn {
return useLive({
sessionId,
apiPath: 'motivators',
currentUserId,
enabled,
onEvent,
});
}

View File

@@ -1,33 +0,0 @@
'use client';
import { useLive, type LiveEvent } from './useLive';
interface UseSessionLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: LiveEvent) => void;
}
interface UseSessionLiveReturn {
isConnected: boolean;
lastEvent: LiveEvent | null;
error: string | null;
}
export function useSessionLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseSessionLiveOptions): UseSessionLiveReturn {
return useLive({
sessionId,
apiPath: 'sessions',
currentUserId,
enabled,
onEvent,
});
}
export type { LiveEvent };

View File

@@ -1,31 +0,0 @@
import { useLive, type LiveEvent } from './useLive';
interface UseWeatherLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: WeatherLiveEvent) => void;
}
interface UseWeatherLiveReturn {
isConnected: boolean;
lastEvent: WeatherLiveEvent | null;
error: string | null;
}
export type WeatherLiveEvent = LiveEvent;
export function useWeatherLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseWeatherLiveOptions): UseWeatherLiveReturn {
return useLive({
sessionId,
apiPath: 'weather',
currentUserId,
enabled,
onEvent,
});
}

View File

@@ -1,31 +0,0 @@
import { useLive, type LiveEvent } from './useLive';
interface UseWeeklyCheckInLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: WeeklyCheckInLiveEvent) => void;
}
interface UseWeeklyCheckInLiveReturn {
isConnected: boolean;
lastEvent: WeeklyCheckInLiveEvent | null;
error: string | null;
}
export type WeeklyCheckInLiveEvent = LiveEvent;
export function useWeeklyCheckInLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseWeeklyCheckInLiveOptions): UseWeeklyCheckInLiveReturn {
return useLive({
sessionId,
apiPath: 'weekly-checkin',
currentUserId,
enabled,
onEvent,
});
}

View File

@@ -1,33 +0,0 @@
'use client';
import { useLive, type LiveEvent } from './useLive';
interface UseYearReviewLiveOptions {
sessionId: string;
currentUserId?: string;
enabled?: boolean;
onEvent?: (event: YearReviewLiveEvent) => void;
}
interface UseYearReviewLiveReturn {
isConnected: boolean;
lastEvent: YearReviewLiveEvent | null;
error: string | null;
}
export type YearReviewLiveEvent = LiveEvent;
export function useYearReviewLive({
sessionId,
currentUserId,
enabled = true,
onEvent,
}: UseYearReviewLiveOptions): UseYearReviewLiveReturn {
return useLive({
sessionId,
apiPath: 'year-review',
currentUserId,
enabled,
onEvent,
});
}

View File

@@ -2,6 +2,21 @@
* Shared utilities for share modals across workshop types.
*/
import type { ShareRole } from '@prisma/client';
export interface ShareUser {
id: string;
name: string | null;
email: string;
}
export interface Share {
id: string;
role: ShareRole;
user: ShareUser;
createdAt?: Date;
}
export interface TeamMemberUser {
id: string;
email: string;