feat: enhance session management with sharing capabilities, real-time event synchronization, and UI updates for session display
This commit is contained in:
69
src/actions/share.ts
Normal file
69
src/actions/share.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { auth } from '@/lib/auth';
|
||||
import {
|
||||
shareSession,
|
||||
removeShare,
|
||||
getSessionShares,
|
||||
} from '@/services/sessions';
|
||||
import type { ShareRole } from '@prisma/client';
|
||||
|
||||
export async function shareSessionAction(
|
||||
sessionId: string,
|
||||
email: string,
|
||||
role: ShareRole = 'EDITOR'
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
try {
|
||||
const share = await shareSession(sessionId, session.user.id, email, role);
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: share };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Erreur inconnue';
|
||||
if (message === 'User not found') {
|
||||
return { success: false, error: "Aucun utilisateur trouvé avec cet email" };
|
||||
}
|
||||
if (message === 'Cannot share session with yourself') {
|
||||
return { success: false, error: "Vous ne pouvez pas partager avec vous-même" };
|
||||
}
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeShareAction(sessionId: string, shareUserId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié' };
|
||||
}
|
||||
|
||||
try {
|
||||
await removeShare(sessionId, session.user.id, shareUserId);
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Erreur inconnue';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSharesAction(sessionId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'Non authentifié', data: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const shares = await getSessionShares(sessionId, session.user.id);
|
||||
return { success: true, data: shares };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Erreur inconnue';
|
||||
return { success: false, error: message, data: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ export async function createSwotItem(
|
||||
|
||||
try {
|
||||
const item = await sessionsService.createSwotItem(sessionId, data);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', {
|
||||
itemId: item.id,
|
||||
content: item.content,
|
||||
category: item.category,
|
||||
});
|
||||
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -40,6 +48,13 @@ export async function updateSwotItem(
|
||||
|
||||
try {
|
||||
const item = await sessionsService.updateSwotItem(itemId, data);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_UPDATED', {
|
||||
itemId: item.id,
|
||||
...data,
|
||||
});
|
||||
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -56,6 +71,12 @@ export async function deleteSwotItem(itemId: string, sessionId: string) {
|
||||
|
||||
try {
|
||||
await sessionsService.deleteSwotItem(itemId);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_DELETED', {
|
||||
itemId,
|
||||
});
|
||||
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -72,6 +93,15 @@ export async function duplicateSwotItem(itemId: string, sessionId: string) {
|
||||
|
||||
try {
|
||||
const item = await sessionsService.duplicateSwotItem(itemId);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_CREATED', {
|
||||
itemId: item.id,
|
||||
content: item.content,
|
||||
category: item.category,
|
||||
duplicatedFrom: itemId,
|
||||
});
|
||||
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -93,6 +123,14 @@ export async function moveSwotItem(
|
||||
|
||||
try {
|
||||
const item = await sessionsService.moveSwotItem(itemId, newCategory, newOrder);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ITEM_MOVED', {
|
||||
itemId: item.id,
|
||||
newCategory,
|
||||
newOrder,
|
||||
});
|
||||
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: item };
|
||||
} catch (error) {
|
||||
@@ -121,6 +159,14 @@ export async function createAction(
|
||||
|
||||
try {
|
||||
const action = await sessionsService.createAction(sessionId, data);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_CREATED', {
|
||||
actionId: action.id,
|
||||
title: action.title,
|
||||
linkedItemIds: data.linkedItemIds,
|
||||
});
|
||||
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: action };
|
||||
} catch (error) {
|
||||
@@ -146,6 +192,13 @@ export async function updateAction(
|
||||
|
||||
try {
|
||||
const action = await sessionsService.updateAction(actionId, data);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_UPDATED', {
|
||||
actionId: action.id,
|
||||
...data,
|
||||
});
|
||||
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true, data: action };
|
||||
} catch (error) {
|
||||
@@ -162,6 +215,12 @@ export async function deleteAction(actionId: string, sessionId: string) {
|
||||
|
||||
try {
|
||||
await sessionsService.deleteAction(actionId);
|
||||
|
||||
// Emit event for real-time sync
|
||||
await sessionsService.createSessionEvent(sessionId, session.user.id, 'ACTION_DELETED', {
|
||||
actionId,
|
||||
});
|
||||
|
||||
revalidatePath(`/sessions/${sessionId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
114
src/app/api/sessions/[id]/subscribe/route.ts
Normal file
114
src/app/api/sessions/[id]/subscribe/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { canAccessSession, getSessionEvents } from '@/services/sessions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Store active connections per session
|
||||
const connections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id: sessionId } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Check access
|
||||
const hasAccess = await canAccessSession(sessionId, session.user.id);
|
||||
if (!hasAccess) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
let lastEventTime = new Date();
|
||||
let controller: ReadableStreamDefaultController;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(ctrl) {
|
||||
controller = ctrl;
|
||||
|
||||
// Register connection
|
||||
if (!connections.has(sessionId)) {
|
||||
connections.set(sessionId, new Set());
|
||||
}
|
||||
connections.get(sessionId)!.add(controller);
|
||||
|
||||
// Send initial ping
|
||||
const encoder = new TextEncoder();
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`)
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
// Remove connection on close
|
||||
connections.get(sessionId)?.delete(controller);
|
||||
if (connections.get(sessionId)?.size === 0) {
|
||||
connections.delete(sessionId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Poll for new events (simple approach, works with any DB)
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const events = await getSessionEvents(sessionId, lastEventTime);
|
||||
if (events.length > 0) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const event of events) {
|
||||
// Don't send events to the user who created them
|
||||
if (event.userId !== userId) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
type: event.type,
|
||||
payload: JSON.parse(event.payload),
|
||||
user: event.user,
|
||||
timestamp: event.createdAt,
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
lastEventTime = event.createdAt;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Connection might be closed
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 1000); // Poll every second
|
||||
|
||||
// Cleanup on abort
|
||||
request.signal.addEventListener('abort', () => {
|
||||
clearInterval(pollInterval);
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to broadcast to all connections (called from actions)
|
||||
export function broadcastToSession(sessionId: string, event: object) {
|
||||
const sessionConnections = connections.get(sessionId);
|
||||
if (!sessionConnections) return;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
|
||||
|
||||
for (const controller of sessionConnections) {
|
||||
try {
|
||||
controller.enqueue(message);
|
||||
} catch {
|
||||
// Connection closed, will be cleaned up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getSessionById } from '@/services/sessions';
|
||||
import { SwotBoard } from '@/components/swot/SwotBoard';
|
||||
import { SessionLiveWrapper } from '@/components/collaboration';
|
||||
import { Badge } from '@/components/ui';
|
||||
|
||||
interface SessionPageProps {
|
||||
@@ -33,6 +34,11 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{session.title}</span>
|
||||
{!session.isOwner && (
|
||||
<Badge variant="accent" className="ml-2">
|
||||
Partagé par {session.user.name || session.user.email}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -56,12 +62,20 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SWOT Board */}
|
||||
<SwotBoard
|
||||
{/* Live Session Wrapper */}
|
||||
<SessionLiveWrapper
|
||||
sessionId={session.id}
|
||||
items={session.items}
|
||||
actions={session.actions}
|
||||
/>
|
||||
sessionTitle={session.title}
|
||||
shares={session.shares}
|
||||
isOwner={session.isOwner}
|
||||
canEdit={session.canEdit}
|
||||
>
|
||||
<SwotBoard
|
||||
sessionId={session.id}
|
||||
items={session.items}
|
||||
actions={session.actions}
|
||||
/>
|
||||
</SessionLiveWrapper>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { prisma } from '@/services/database';
|
||||
import { getSessionsByUserId } from '@/services/sessions';
|
||||
import { Card, CardContent, Badge, Button } from '@/components/ui';
|
||||
|
||||
async function getSessions(userId: string) {
|
||||
return prisma.session.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
actions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export default async function SessionsPage() {
|
||||
const session = await auth();
|
||||
|
||||
@@ -25,7 +10,11 @@ export default async function SessionsPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessions = await getSessions(session.user.id);
|
||||
const sessions = await getSessionsByUserId(session.user.id);
|
||||
|
||||
// Separate owned vs shared sessions
|
||||
const ownedSessions = sessions.filter((s) => s.isOwner);
|
||||
const sharedSessions = sessions.filter((s) => !s.isOwner);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
@@ -61,45 +50,89 @@ export default async function SessionsPage() {
|
||||
</Link>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sessions.map((s) => (
|
||||
<Link key={s.id} href={`/sessions/${s.id}`}>
|
||||
<Card hover className="h-full p-6">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground line-clamp-1">
|
||||
{s.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted">{s.collaborator}</p>
|
||||
</div>
|
||||
<span className="text-2xl">📊</span>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{/* My Sessions */}
|
||||
{ownedSessions.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
📁 Mes sessions ({ownedSessions.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{ownedSessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Badge variant="primary">
|
||||
{s._count.items} items
|
||||
</Badge>
|
||||
<Badge variant="success">
|
||||
{s._count.actions} actions
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
Mis à jour le{' '}
|
||||
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
{/* Shared Sessions */}
|
||||
{sharedSessions.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
🤝 Sessions partagées avec moi ({sharedSessions.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sharedSessions.map((s) => (
|
||||
<SessionCard key={s.id} session={s} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
type SessionWithMeta = Awaited<ReturnType<typeof getSessionsByUserId>>[number];
|
||||
|
||||
function SessionCard({ session: s }: { session: SessionWithMeta }) {
|
||||
return (
|
||||
<Link href={`/sessions/${s.id}`}>
|
||||
<Card hover className="h-full p-6">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground line-clamp-1">
|
||||
{s.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted">{s.collaborator}</p>
|
||||
{!s.isOwner && (
|
||||
<p className="text-xs text-muted mt-1">
|
||||
Par {s.user.name || s.user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!s.isOwner && (
|
||||
<Badge variant={s.role === 'EDITOR' ? 'primary' : 'warning'}>
|
||||
{s.role === 'EDITOR' ? '✏️' : '👁️'}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-2xl">📊</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Badge variant="primary">
|
||||
{s._count.items} items
|
||||
</Badge>
|
||||
<Badge variant="success">
|
||||
{s._count.actions} actions
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
Mis à jour le{' '}
|
||||
{new Date(s.updatedAt).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
36
src/components/collaboration/LiveIndicator.tsx
Normal file
36
src/components/collaboration/LiveIndicator.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
interface LiveIndicatorProps {
|
||||
isConnected: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function LiveIndicator({ isConnected, error }: LiveIndicatorProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-destructive/10 px-3 py-1.5 text-sm text-destructive">
|
||||
<span className="h-2 w-2 rounded-full bg-destructive" />
|
||||
<span>Hors ligne</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors ${
|
||||
isConnected
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-yellow/10 text-yellow'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
isConnected ? 'bg-success animate-pulse' : 'bg-yellow'
|
||||
}`}
|
||||
/>
|
||||
<span>{isConnected ? 'Live' : 'Connexion...'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
136
src/components/collaboration/SessionLiveWrapper.tsx
Normal file
136
src/components/collaboration/SessionLiveWrapper.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useSessionLive, type LiveEvent } from '@/hooks/useSessionLive';
|
||||
import { LiveIndicator } from './LiveIndicator';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
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 SessionLiveWrapperProps {
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
canEdit: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SessionLiveWrapper({
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
shares,
|
||||
isOwner,
|
||||
canEdit,
|
||||
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,
|
||||
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) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-card bg-primary/10 text-xs font-medium text-primary"
|
||||
title={share.user.name || share.user.email}
|
||||
>
|
||||
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{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)}
|
||||
sessionId={sessionId}
|
||||
sessionTitle={sessionTitle}
|
||||
shares={shares}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
181
src/components/collaboration/ShareModal.tsx
Normal file
181
src/components/collaboration/ShareModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { shareSessionAction, removeShareAction } from '@/actions/share';
|
||||
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 ShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export function ShareModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
shares,
|
||||
isOwner,
|
||||
}: ShareModalProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState<ShareRole>('EDITOR');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
async function handleShare(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await shareSessionAction(sessionId, email, role);
|
||||
if (result.success) {
|
||||
setEmail('');
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors du partage');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRemove(userId: string) {
|
||||
startTransition(async () => {
|
||||
await removeShareAction(sessionId, userId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Partager la session">
|
||||
<div className="space-y-6">
|
||||
{/* Session info */}
|
||||
<div>
|
||||
<p className="text-sm text-muted">Session</p>
|
||||
<p className="font-medium text-foreground">{sessionTitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Share form (only for owner) */}
|
||||
{isOwner && (
|
||||
<form onSubmit={handleShare} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email de l'utilisateur"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="flex-1"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as ShareRole)}
|
||||
className="rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="EDITOR">Éditeur</option>
|
||||
<option value="VIEWER">Lecteur</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<Button type="submit" disabled={isPending || !email} className="w-full">
|
||||
{isPending ? 'Partage...' : 'Partager'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Current shares */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Collaborateurs ({shares.length})
|
||||
</p>
|
||||
|
||||
{shares.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
Aucun collaborateur pour le moment
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{shares.map((share) => (
|
||||
<li
|
||||
key={share.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
|
||||
{share.user.name?.[0]?.toUpperCase() || share.user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{share.user.name || share.user.email}
|
||||
</p>
|
||||
{share.user.name && (
|
||||
<p className="text-xs text-muted">{share.user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={share.role === 'EDITOR' ? 'primary' : 'default'}>
|
||||
{share.role === 'EDITOR' ? 'Éditeur' : 'Lecteur'}
|
||||
</Badge>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleRemove(share.user.id)}
|
||||
disabled={isPending}
|
||||
className="rounded p-1 text-muted hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Retirer l'accès"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="rounded-lg bg-primary/5 p-3">
|
||||
<p className="text-xs text-muted">
|
||||
<strong>Éditeur</strong> : peut modifier les items et actions
|
||||
<br />
|
||||
<strong>Lecteur</strong> : peut uniquement consulter
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
4
src/components/collaboration/index.ts
Normal file
4
src/components/collaboration/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { LiveIndicator } from './LiveIndicator';
|
||||
export { ShareModal } from './ShareModal';
|
||||
export { SessionLiveWrapper } from './SessionLiveWrapper';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
type BadgeVariant =
|
||||
export type BadgeVariant =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'strength'
|
||||
@@ -9,7 +9,8 @@ type BadgeVariant =
|
||||
| 'threat'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'destructive';
|
||||
| 'destructive'
|
||||
| 'accent';
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
@@ -25,6 +26,7 @@ const variantStyles: Record<BadgeVariant, string> = {
|
||||
success: 'bg-success/10 text-success border-success/20',
|
||||
warning: 'bg-warning/10 text-warning border-warning/20',
|
||||
destructive: 'bg-destructive/10 text-destructive border-destructive/20',
|
||||
accent: 'bg-accent/10 text-accent border-accent/20',
|
||||
};
|
||||
|
||||
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
|
||||
118
src/hooks/useSessionLive.ts
Normal file
118
src/hooks/useSessionLive.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export type LiveEvent = {
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
user?: { id: string; name: string | null; email: string };
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
interface UseSessionLiveOptions {
|
||||
sessionId: string;
|
||||
enabled?: boolean;
|
||||
onEvent?: (event: LiveEvent) => void;
|
||||
}
|
||||
|
||||
interface UseSessionLiveReturn {
|
||||
isConnected: boolean;
|
||||
lastEvent: LiveEvent | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useSessionLive({
|
||||
sessionId,
|
||||
enabled = true,
|
||||
onEvent,
|
||||
}: UseSessionLiveOptions): UseSessionLiveReturn {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [lastEvent, setLastEvent] = useState<LiveEvent | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const onEventRef = useRef(onEvent);
|
||||
|
||||
// Keep onEvent ref updated
|
||||
useEffect(() => {
|
||||
onEventRef.current = onEvent;
|
||||
}, [onEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') return;
|
||||
|
||||
function connect() {
|
||||
// Close existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
try {
|
||||
const eventSource = new EventSource(`/api/sessions/${sessionId}/subscribe`);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as LiveEvent;
|
||||
|
||||
// Handle connection event
|
||||
if (data.type === 'connected') {
|
||||
return;
|
||||
}
|
||||
|
||||
setLastEvent(data);
|
||||
onEventRef.current?.(data);
|
||||
|
||||
// Refresh the page data when we receive an event
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setIsConnected(false);
|
||||
eventSource.close();
|
||||
|
||||
// Exponential backoff reconnect
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
||||
reconnectAttemptsRef.current++;
|
||||
|
||||
if (reconnectAttemptsRef.current <= 5) {
|
||||
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
||||
} else {
|
||||
setError('Connexion perdue. Rechargez la page.');
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
setError('Impossible de se connecter au mode live');
|
||||
console.error('Failed to create EventSource:', e);
|
||||
}
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, enabled, router]);
|
||||
|
||||
return { isConnected, lastEvent, error };
|
||||
}
|
||||
|
||||
@@ -1,32 +1,70 @@
|
||||
import { prisma } from '@/services/database';
|
||||
import type { SwotCategory } from '@prisma/client';
|
||||
import type { SwotCategory, ShareRole } from '@prisma/client';
|
||||
|
||||
// ============================================
|
||||
// Session CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getSessionsByUserId(userId: string) {
|
||||
return prisma.session.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
actions: true,
|
||||
// Get owned sessions + shared sessions
|
||||
const [owned, shared] = await Promise.all([
|
||||
prisma.session.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
actions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
prisma.sessionShare.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
session: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
actions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Mark owned sessions and merge with shared
|
||||
const ownedWithRole = owned.map((s) => ({ ...s, isOwner: true as const, role: 'OWNER' as const }));
|
||||
const sharedWithRole = shared.map((s) => ({
|
||||
...s.session,
|
||||
isOwner: false as const,
|
||||
role: s.role,
|
||||
sharedAt: s.createdAt,
|
||||
}));
|
||||
|
||||
return [...ownedWithRole, ...sharedWithRole].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSessionById(sessionId: string, userId: string) {
|
||||
return prisma.session.findFirst({
|
||||
// Check if user owns the session OR has it shared
|
||||
const session = await prisma.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
userId,
|
||||
OR: [
|
||||
{ userId }, // Owner
|
||||
{ shares: { some: { userId } } }, // Shared with user
|
||||
],
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
items: {
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
@@ -40,8 +78,45 @@ export async function getSessionById(sessionId: string, userId: string) {
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
shares: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Determine user's role
|
||||
const isOwner = session.userId === userId;
|
||||
const share = session.shares.find((s) => s.userId === userId);
|
||||
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
|
||||
const canEdit = isOwner || role === 'EDITOR';
|
||||
|
||||
return { ...session, isOwner, role, canEdit };
|
||||
}
|
||||
|
||||
// Check if user can access session (owner or shared)
|
||||
export async function canAccessSession(sessionId: string, userId: string) {
|
||||
const count = await prisma.session.count({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [{ userId }, { shares: { some: { userId } } }],
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
// Check if user can edit session (owner or EDITOR role)
|
||||
export async function canEditSession(sessionId: string, userId: string) {
|
||||
const count = await prisma.session.count({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
export async function createSession(userId: string, data: { title: string; collaborator: string }) {
|
||||
@@ -238,3 +313,131 @@ export async function unlinkItemFromAction(actionId: string, swotItemId: string)
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Session Sharing
|
||||
// ============================================
|
||||
|
||||
export async function shareSession(
|
||||
sessionId: string,
|
||||
ownerId: string,
|
||||
targetEmail: string,
|
||||
role: ShareRole = 'EDITOR'
|
||||
) {
|
||||
// Verify owner
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: sessionId, userId: ownerId },
|
||||
});
|
||||
if (!session) {
|
||||
throw new Error('Session not found or not owned');
|
||||
}
|
||||
|
||||
// Find target user
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { email: targetEmail },
|
||||
});
|
||||
if (!targetUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Can't share with yourself
|
||||
if (targetUser.id === ownerId) {
|
||||
throw new Error('Cannot share session with yourself');
|
||||
}
|
||||
|
||||
// Create or update share
|
||||
return prisma.sessionShare.upsert({
|
||||
where: {
|
||||
sessionId_userId: { sessionId, userId: targetUser.id },
|
||||
},
|
||||
update: { role },
|
||||
create: {
|
||||
sessionId,
|
||||
userId: targetUser.id,
|
||||
role,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeShare(sessionId: string, ownerId: string, shareUserId: string) {
|
||||
// Verify owner
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: sessionId, userId: ownerId },
|
||||
});
|
||||
if (!session) {
|
||||
throw new Error('Session not found or not owned');
|
||||
}
|
||||
|
||||
return prisma.sessionShare.deleteMany({
|
||||
where: { sessionId, userId: shareUserId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSessionShares(sessionId: string, userId: string) {
|
||||
// Verify access
|
||||
if (!(await canAccessSession(sessionId, userId))) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
|
||||
return prisma.sessionShare.findMany({
|
||||
where: { sessionId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Session Events (for real-time sync)
|
||||
// ============================================
|
||||
|
||||
export type SessionEventType =
|
||||
| 'ITEM_CREATED'
|
||||
| 'ITEM_UPDATED'
|
||||
| 'ITEM_DELETED'
|
||||
| 'ITEM_MOVED'
|
||||
| 'ACTION_CREATED'
|
||||
| 'ACTION_UPDATED'
|
||||
| 'ACTION_DELETED'
|
||||
| 'SESSION_UPDATED';
|
||||
|
||||
export async function createSessionEvent(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
type: SessionEventType,
|
||||
payload: Record<string, unknown>
|
||||
) {
|
||||
return prisma.sessionEvent.create({
|
||||
data: {
|
||||
sessionId,
|
||||
userId,
|
||||
type,
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSessionEvents(sessionId: string, since?: Date) {
|
||||
return prisma.sessionEvent.findMany({
|
||||
where: {
|
||||
sessionId,
|
||||
...(since && { createdAt: { gt: since } }),
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLatestEventTimestamp(sessionId: string) {
|
||||
const event = await prisma.sessionEvent.findFirst({
|
||||
where: { sessionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
return event?.createdAt;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user