feat: enhance session management by resolving collaborators to users and integrating CollaboratorDisplay component across motivators and sessions pages
This commit is contained in:
@@ -3,7 +3,7 @@ import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getMotivatorSessionById } from '@/services/moving-motivators';
|
||||
import { MotivatorBoard, MotivatorLiveWrapper } from '@/components/moving-motivators';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||
import { EditableMotivatorTitle } from './EditableTitle';
|
||||
|
||||
interface MotivatorSessionPageProps {
|
||||
@@ -48,9 +48,13 @@ export default async function MotivatorSessionPage({ params }: MotivatorSessionP
|
||||
initialTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
/>
|
||||
<p className="mt-1 text-lg text-muted">
|
||||
👤 {session.participant}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay
|
||||
collaborator={session.resolvedParticipant}
|
||||
size="lg"
|
||||
showEmail
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
|
||||
import { Card, CardContent, Badge, Button } from '@/components/ui';
|
||||
import { Card, CardContent, Badge, Button, CollaboratorDisplay } from '@/components/ui';
|
||||
|
||||
export default async function MotivatorsPage() {
|
||||
const session = await auth();
|
||||
@@ -95,7 +95,9 @@ function SessionCard({ session: s }: { session: SessionWithMeta }) {
|
||||
<h3 className="font-semibold text-foreground line-clamp-1">
|
||||
{s.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted">{s.participant}</p>
|
||||
<div className="mt-1">
|
||||
<CollaboratorDisplay collaborator={s.resolvedParticipant} size="sm" />
|
||||
</div>
|
||||
{!s.isOwner && (
|
||||
<p className="text-xs text-muted mt-1">
|
||||
Par {s.user.name || s.user.email}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, Badge, Button, Modal, ModalFooter, Input } from '@/components/ui';
|
||||
import { Card, Badge, Button, Modal, ModalFooter, Input, CollaboratorDisplay } from '@/components/ui';
|
||||
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
|
||||
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
|
||||
|
||||
@@ -20,10 +20,20 @@ interface Share {
|
||||
user: ShareUser;
|
||||
}
|
||||
|
||||
interface ResolvedCollaborator {
|
||||
raw: string;
|
||||
matchedUser: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface SwotSession {
|
||||
id: string;
|
||||
title: string;
|
||||
collaborator: string;
|
||||
resolvedCollaborator: ResolvedCollaborator;
|
||||
updatedAt: Date;
|
||||
isOwner: boolean;
|
||||
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
@@ -37,6 +47,7 @@ interface MotivatorSession {
|
||||
id: string;
|
||||
title: string;
|
||||
participant: string;
|
||||
resolvedParticipant: ResolvedCollaborator;
|
||||
updatedAt: Date;
|
||||
isOwner: boolean;
|
||||
role: 'OWNER' | 'VIEWER' | 'EDITOR';
|
||||
@@ -60,20 +71,45 @@ function getParticipant(session: AnySession): string {
|
||||
: (session as MotivatorSession).participant;
|
||||
}
|
||||
|
||||
// Group sessions by participant
|
||||
// Helper to get resolved collaborator from any session
|
||||
function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
|
||||
return session.workshopType === 'swot'
|
||||
? (session as SwotSession).resolvedCollaborator
|
||||
: (session as MotivatorSession).resolvedParticipant;
|
||||
}
|
||||
|
||||
// Get display name for grouping - prefer matched user name
|
||||
function getDisplayName(session: AnySession): string {
|
||||
const resolved = getResolvedCollaborator(session);
|
||||
if (resolved.matchedUser?.name) {
|
||||
return resolved.matchedUser.name;
|
||||
}
|
||||
return resolved.raw;
|
||||
}
|
||||
|
||||
// Get grouping key - use matched user ID if available, otherwise normalized raw string
|
||||
function getGroupKey(session: AnySession): string {
|
||||
const resolved = getResolvedCollaborator(session);
|
||||
// If we have a matched user, use their ID as key (ensures same person = same group)
|
||||
if (resolved.matchedUser) {
|
||||
return `user:${resolved.matchedUser.id}`;
|
||||
}
|
||||
// Otherwise, normalize the raw string
|
||||
return `raw:${resolved.raw.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
// Group sessions by participant (using matched user ID when available)
|
||||
function groupByPerson(sessions: AnySession[]): Map<string, AnySession[]> {
|
||||
const grouped = new Map<string, AnySession[]>();
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const participant = getParticipant(session).trim().toLowerCase();
|
||||
const displayName = getParticipant(session).trim();
|
||||
const key = getGroupKey(session);
|
||||
|
||||
// Use normalized key but store with original display name
|
||||
const existing = grouped.get(participant);
|
||||
const existing = grouped.get(key);
|
||||
if (existing) {
|
||||
existing.push(session);
|
||||
} else {
|
||||
grouped.set(participant, [session]);
|
||||
grouped.set(key, [session]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -155,16 +191,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{sortedPersons.map(([personKey, sessions]) => {
|
||||
const displayName = getParticipant(sessions[0]);
|
||||
const resolved = getResolvedCollaborator(sessions[0]);
|
||||
return (
|
||||
<section key={personKey}>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary text-sm">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{displayName}
|
||||
<Badge variant="primary" className="ml-2">
|
||||
{sessions.length}
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-3">
|
||||
<CollaboratorDisplay collaborator={resolved} size="md" />
|
||||
<Badge variant="primary">
|
||||
{sessions.length} atelier{sessions.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -336,12 +369,15 @@ function SessionCard({ session }: { session: AnySession }) {
|
||||
</div>
|
||||
|
||||
{/* Participant + Owner info */}
|
||||
<p className="text-sm text-muted mb-3 line-clamp-1">
|
||||
👤 {participant}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<CollaboratorDisplay
|
||||
collaborator={getResolvedCollaborator(session)}
|
||||
size="sm"
|
||||
/>
|
||||
{!session.isOwner && (
|
||||
<span className="text-xs"> · par {session.user.name || session.user.email}</span>
|
||||
<span className="text-xs text-muted">· par {session.user.name || session.user.email}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer: Stats + Avatars + Date */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSessionById } from '@/services/sessions';
|
||||
import { SwotBoard } from '@/components/swot/SwotBoard';
|
||||
import { SessionLiveWrapper } from '@/components/collaboration';
|
||||
import { EditableTitle } from '@/components/session';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { Badge, CollaboratorDisplay } from '@/components/ui';
|
||||
|
||||
interface SessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -49,9 +49,13 @@ export default async function SessionPage({ params }: SessionPageProps) {
|
||||
initialTitle={session.title}
|
||||
isOwner={session.isOwner}
|
||||
/>
|
||||
<p className="mt-1 text-lg text-muted">
|
||||
👤 {session.collaborator}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CollaboratorDisplay
|
||||
collaborator={session.resolvedCollaborator}
|
||||
size="lg"
|
||||
showEmail
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="primary">{session.items.length} items</Badge>
|
||||
|
||||
82
src/components/ui/CollaboratorDisplay.tsx
Normal file
82
src/components/ui/CollaboratorDisplay.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
|
||||
interface CollaboratorDisplayProps {
|
||||
collaborator: {
|
||||
raw: string;
|
||||
matchedUser: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
};
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showEmail?: boolean;
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: { avatar: 24, text: 'text-sm', gap: 'gap-1.5' },
|
||||
md: { avatar: 32, text: 'text-base', gap: 'gap-2' },
|
||||
lg: { avatar: 40, text: 'text-lg', gap: 'gap-3' },
|
||||
};
|
||||
|
||||
export function CollaboratorDisplay({
|
||||
collaborator,
|
||||
size = 'md',
|
||||
showEmail = false,
|
||||
}: CollaboratorDisplayProps) {
|
||||
const { raw, matchedUser } = collaborator;
|
||||
const config = sizeConfig[size];
|
||||
|
||||
// If we have a matched user, show their avatar and name
|
||||
if (matchedUser) {
|
||||
const displayName = matchedUser.name || matchedUser.email.split('@')[0];
|
||||
|
||||
return (
|
||||
<div className={`flex items-center ${config.gap}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getGravatarUrl(matchedUser.email, config.avatar * 2)}
|
||||
alt={displayName}
|
||||
width={config.avatar}
|
||||
height={config.avatar}
|
||||
className="rounded-full border border-border"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className={`${config.text} font-medium text-foreground truncate block`}>
|
||||
{displayName}
|
||||
</span>
|
||||
{showEmail && matchedUser.name && (
|
||||
<span className="text-xs text-muted truncate block">{matchedUser.email}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No match - just show the raw name with a default person icon
|
||||
return (
|
||||
<div className={`flex items-center ${config.gap}`}>
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full border border-border bg-card-hover"
|
||||
style={{ width: config.avatar, height: config.avatar }}
|
||||
>
|
||||
<svg
|
||||
className="text-muted"
|
||||
style={{ width: config.avatar * 0.5, height: config.avatar * 0.5 }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className={`${config.text} text-muted truncate`}>{raw}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export { Avatar } from './Avatar';
|
||||
export { Badge } from './Badge';
|
||||
export { Button } from './Button';
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||
export { CollaboratorDisplay } from './CollaboratorDisplay';
|
||||
export { Input } from './Input';
|
||||
export { Modal, ModalFooter } from './Modal';
|
||||
export { Textarea } from './Textarea';
|
||||
|
||||
@@ -60,6 +60,53 @@ export async function getUserByEmail(email: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if string looks like an email
|
||||
function isEmail(str: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
||||
}
|
||||
|
||||
export interface ResolvedCollaborator {
|
||||
raw: string; // Original value (name or email)
|
||||
matchedUser: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Resolve collaborator string to user - try email first, then name
|
||||
export async function resolveCollaborator(collaborator: string): Promise<ResolvedCollaborator> {
|
||||
const trimmed = collaborator.trim();
|
||||
|
||||
// 1. Try email match first
|
||||
if (isEmail(trimmed)) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: trimmed.toLowerCase() },
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
return { raw: collaborator, matchedUser: user };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: try matching by name (case-insensitive via raw query for SQLite)
|
||||
// SQLite LIKE is case-insensitive by default for ASCII
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
name: { not: null },
|
||||
},
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
|
||||
const normalizedSearch = trimmed.toLowerCase();
|
||||
const userByName = users.find(
|
||||
(u) => u.name?.toLowerCase() === normalizedSearch
|
||||
) || null;
|
||||
|
||||
return { raw: collaborator, matchedUser: userByName };
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
return prisma.user.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@/services/database';
|
||||
import { resolveCollaborator } from '@/services/auth';
|
||||
import type { ShareRole, MotivatorType } from '@prisma/client';
|
||||
|
||||
// ============================================
|
||||
@@ -56,9 +57,19 @@ export async function getMotivatorSessionsByUserId(userId: string) {
|
||||
sharedAt: s.createdAt,
|
||||
}));
|
||||
|
||||
return [...ownedWithRole, ...sharedWithRole].sort(
|
||||
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
|
||||
// Resolve participants to users
|
||||
const sessionsWithResolved = await Promise.all(
|
||||
allSessions.map(async (s) => ({
|
||||
...s,
|
||||
resolvedParticipant: await resolveCollaborator(s.participant),
|
||||
}))
|
||||
);
|
||||
|
||||
return sessionsWithResolved;
|
||||
}
|
||||
|
||||
export async function getMotivatorSessionById(sessionId: string, userId: string) {
|
||||
@@ -92,7 +103,10 @@ export async function getMotivatorSessionById(sessionId: string, userId: string)
|
||||
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
|
||||
const canEdit = isOwner || role === 'EDITOR';
|
||||
|
||||
return { ...session, isOwner, role, canEdit };
|
||||
// Resolve participant to user if it's an email
|
||||
const resolvedParticipant = await resolveCollaborator(session.participant);
|
||||
|
||||
return { ...session, isOwner, role, canEdit, resolvedParticipant };
|
||||
}
|
||||
|
||||
// Check if user can access session (owner or shared)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@/services/database';
|
||||
import { resolveCollaborator } from '@/services/auth';
|
||||
import type { SwotCategory, ShareRole } from '@prisma/client';
|
||||
|
||||
// ============================================
|
||||
@@ -58,9 +59,19 @@ export async function getSessionsByUserId(userId: string) {
|
||||
sharedAt: s.createdAt,
|
||||
}));
|
||||
|
||||
return [...ownedWithRole, ...sharedWithRole].sort(
|
||||
const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
|
||||
// Resolve collaborators to users
|
||||
const sessionsWithResolved = await Promise.all(
|
||||
allSessions.map(async (s) => ({
|
||||
...s,
|
||||
resolvedCollaborator: await resolveCollaborator(s.collaborator),
|
||||
}))
|
||||
);
|
||||
|
||||
return sessionsWithResolved;
|
||||
}
|
||||
|
||||
export async function getSessionById(sessionId: string, userId: string) {
|
||||
@@ -104,7 +115,10 @@ export async function getSessionById(sessionId: string, userId: string) {
|
||||
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
|
||||
const canEdit = isOwner || role === 'EDITOR';
|
||||
|
||||
return { ...session, isOwner, role, canEdit };
|
||||
// Resolve collaborator to user if it's an email
|
||||
const resolvedCollaborator = await resolveCollaborator(session.collaborator);
|
||||
|
||||
return { ...session, isOwner, role, canEdit, resolvedCollaborator };
|
||||
}
|
||||
|
||||
// Check if user can access session (owner or shared)
|
||||
|
||||
Reference in New Issue
Block a user