feat: implement Moving Motivators feature with session management, real-time event handling, and UI components for enhanced user experience
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { useState } from 'react';
|
||||
@@ -9,23 +10,89 @@ export function Header() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { data: session, status } = useSession();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [workshopsOpen, setWorkshopsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActiveLink = (path: string) => pathname.startsWith(path);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-2xl">📊</span>
|
||||
<span className="text-xl font-bold text-foreground">SWOT Manager</span>
|
||||
<span className="text-2xl">🚀</span>
|
||||
<span className="text-xl font-bold text-foreground">Workshop Manager</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-4">
|
||||
{status === 'authenticated' && session?.user && (
|
||||
<Link
|
||||
href="/sessions"
|
||||
className="text-muted transition-colors hover:text-foreground"
|
||||
>
|
||||
Mes Sessions
|
||||
</Link>
|
||||
<>
|
||||
{/* All Workshops Link */}
|
||||
<Link
|
||||
href="/sessions"
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isActiveLink('/sessions') && !isActiveLink('/sessions/')
|
||||
? 'text-primary'
|
||||
: 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Mes Ateliers
|
||||
</Link>
|
||||
|
||||
{/* Workshops Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setWorkshopsOpen(!workshopsOpen)}
|
||||
onBlur={() => setTimeout(() => setWorkshopsOpen(false), 150)}
|
||||
className={`flex items-center gap-1 text-sm font-medium transition-colors ${
|
||||
isActiveLink('/sessions/') || isActiveLink('/motivators')
|
||||
? 'text-primary'
|
||||
: 'text-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Ateliers
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${workshopsOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{workshopsOpen && (
|
||||
<div className="absolute left-0 z-20 mt-2 w-56 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<Link
|
||||
href="/sessions/new"
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
|
||||
onClick={() => setWorkshopsOpen(false)}
|
||||
>
|
||||
<span className="text-lg">📊</span>
|
||||
<div>
|
||||
<div className="font-medium">Analyse SWOT</div>
|
||||
<div className="text-xs text-muted">Forces, faiblesses, opportunités</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/motivators/new"
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm text-foreground hover:bg-card-hover"
|
||||
onClick={() => setWorkshopsOpen(false)}
|
||||
>
|
||||
<span className="text-lg">🎯</span>
|
||||
<div>
|
||||
<div className="font-medium">Moving Motivators</div>
|
||||
<div className="text-xs text-muted">Motivations intrinsèques</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
143
src/components/moving-motivators/InfluenceZone.tsx
Normal file
143
src/components/moving-motivators/InfluenceZone.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
|
||||
import { MOTIVATOR_BY_TYPE } from '@/lib/types';
|
||||
|
||||
interface InfluenceZoneProps {
|
||||
cards: MotivatorCardType[];
|
||||
onInfluenceChange: (cardId: string, influence: number) => void;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function InfluenceZone({ cards, onInfluenceChange, canEdit }: InfluenceZoneProps) {
|
||||
// Sort by importance (orderIndex)
|
||||
const sortedCards = [...cards].sort((a, b) => b.orderIndex - a.orderIndex);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Legend */}
|
||||
<div className="flex justify-center gap-8 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="text-muted">Influence négative</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-400" />
|
||||
<span className="text-muted">Neutre</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<span className="text-muted">Influence positive</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards with sliders */}
|
||||
<div className="space-y-3">
|
||||
{sortedCards.map((card) => (
|
||||
<InfluenceSlider
|
||||
key={card.id}
|
||||
card={card}
|
||||
onInfluenceChange={onInfluenceChange}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfluenceSlider({
|
||||
card,
|
||||
onInfluenceChange,
|
||||
disabled,
|
||||
}: {
|
||||
card: MotivatorCardType;
|
||||
onInfluenceChange: (cardId: string, influence: number) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const config = MOTIVATOR_BY_TYPE[card.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-4 p-4 rounded-xl border border-border bg-card
|
||||
transition-all hover:shadow-md
|
||||
`}
|
||||
>
|
||||
{/* Card info */}
|
||||
<div className="flex items-center gap-3 w-40 shrink-0">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-xl"
|
||||
style={{ backgroundColor: `${config.color}20` }}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground text-sm">{config.name}</div>
|
||||
<div className="text-xs text-muted">#{card.orderIndex}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Influence slider */}
|
||||
<div className="flex-1 flex items-center gap-4">
|
||||
<span className="text-xs text-red-500 font-medium w-6 text-right">-3</span>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
{/* Track background */}
|
||||
<div className="absolute inset-0 h-2 top-1/2 -translate-y-1/2 rounded-full bg-gradient-to-r from-red-500 via-gray-300 to-green-500" />
|
||||
|
||||
{/* Slider */}
|
||||
<input
|
||||
type="range"
|
||||
min={-3}
|
||||
max={3}
|
||||
value={card.influence}
|
||||
onChange={(e) => onInfluenceChange(card.id, parseInt(e.target.value))}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative w-full h-8 appearance-none bg-transparent cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-6
|
||||
[&::-webkit-slider-thumb]:h-6
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-white
|
||||
[&::-webkit-slider-thumb]:border-2
|
||||
[&::-webkit-slider-thumb]:border-foreground
|
||||
[&::-webkit-slider-thumb]:shadow-md
|
||||
[&::-webkit-slider-thumb]:cursor-grab
|
||||
[&::-webkit-slider-thumb]:active:cursor-grabbing
|
||||
[&::-moz-range-thumb]:w-6
|
||||
[&::-moz-range-thumb]:h-6
|
||||
[&::-moz-range-thumb]:rounded-full
|
||||
[&::-moz-range-thumb]:bg-white
|
||||
[&::-moz-range-thumb]:border-2
|
||||
[&::-moz-range-thumb]:border-foreground
|
||||
[&::-moz-range-thumb]:shadow-md
|
||||
[&::-moz-range-thumb]:cursor-grab
|
||||
disabled:cursor-not-allowed
|
||||
disabled:[&::-webkit-slider-thumb]:cursor-not-allowed
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Zero marker */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-4 bg-foreground/30 rounded-full pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-green-500 font-medium w-6">+3</span>
|
||||
</div>
|
||||
|
||||
{/* Current value */}
|
||||
<div
|
||||
className={`
|
||||
w-12 h-8 rounded-lg flex items-center justify-center font-bold text-sm
|
||||
${card.influence > 0 ? 'bg-green-500/20 text-green-600' : ''}
|
||||
${card.influence < 0 ? 'bg-red-500/20 text-red-600' : ''}
|
||||
${card.influence === 0 ? 'bg-muted/20 text-muted' : ''}
|
||||
`}
|
||||
>
|
||||
{card.influence > 0 ? `+${card.influence}` : card.influence}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
276
src/components/moving-motivators/MotivatorBoard.tsx
Normal file
276
src/components/moving-motivators/MotivatorBoard.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
|
||||
import { MotivatorCard } from './MotivatorCard';
|
||||
import { MotivatorSummary } from './MotivatorSummary';
|
||||
import { InfluenceZone } from './InfluenceZone';
|
||||
import { reorderMotivatorCards, updateCardInfluence } from '@/actions/moving-motivators';
|
||||
|
||||
interface MotivatorBoardProps {
|
||||
sessionId: string;
|
||||
cards: MotivatorCardType[];
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
type Step = 'ranking' | 'influence' | 'summary';
|
||||
|
||||
export function MotivatorBoard({ sessionId, cards: initialCards, canEdit }: MotivatorBoardProps) {
|
||||
const [cards, setCards] = useState(initialCards);
|
||||
const [step, setStep] = useState<Step>('ranking');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Sort cards by orderIndex
|
||||
const sortedCards = [...cards].sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = sortedCards.findIndex((c) => c.id === active.id);
|
||||
const newIndex = sortedCards.findIndex((c) => c.id === over.id);
|
||||
|
||||
const newCards = arrayMove(sortedCards, oldIndex, newIndex).map((card, index) => ({
|
||||
...card,
|
||||
orderIndex: index + 1,
|
||||
}));
|
||||
|
||||
setCards(newCards);
|
||||
|
||||
// Persist to server
|
||||
startTransition(async () => {
|
||||
await reorderMotivatorCards(sessionId, newCards.map((c) => c.id));
|
||||
});
|
||||
}
|
||||
|
||||
function handleInfluenceChange(cardId: string, influence: number) {
|
||||
setCards((prev) =>
|
||||
prev.map((c) => (c.id === cardId ? { ...c, influence } : c))
|
||||
);
|
||||
|
||||
startTransition(async () => {
|
||||
await updateCardInfluence(cardId, sessionId, influence);
|
||||
});
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (step === 'ranking') setStep('influence');
|
||||
else if (step === 'influence') setStep('summary');
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (step === 'influence') setStep('ranking');
|
||||
else if (step === 'summary') setStep('influence');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${isPending ? 'opacity-70' : ''}`}>
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<StepIndicator
|
||||
number={1}
|
||||
label="Classement"
|
||||
active={step === 'ranking'}
|
||||
completed={step !== 'ranking'}
|
||||
onClick={() => setStep('ranking')}
|
||||
/>
|
||||
<div className="h-px w-12 bg-border" />
|
||||
<StepIndicator
|
||||
number={2}
|
||||
label="Influence"
|
||||
active={step === 'influence'}
|
||||
completed={step === 'summary'}
|
||||
onClick={() => setStep('influence')}
|
||||
/>
|
||||
<div className="h-px w-12 bg-border" />
|
||||
<StepIndicator
|
||||
number={3}
|
||||
label="Récapitulatif"
|
||||
active={step === 'summary'}
|
||||
completed={false}
|
||||
onClick={() => setStep('summary')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
{step === 'ranking' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||
Classez vos motivations par importance
|
||||
</h2>
|
||||
<p className="text-muted">
|
||||
Glissez les cartes de gauche (moins important) à droite (plus important)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Importance axis */}
|
||||
<div className="relative">
|
||||
<div className="flex justify-between text-sm text-muted mb-4 px-4">
|
||||
<span>← Moins important</span>
|
||||
<span>Plus important →</span>
|
||||
</div>
|
||||
|
||||
{/* Cards container */}
|
||||
<div className="bg-gradient-to-r from-red-500/10 via-yellow-500/10 to-green-500/10 rounded-2xl p-4 border border-border overflow-x-auto">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedCards.map((c) => c.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<div className="flex gap-2 min-w-max px-2">
|
||||
{sortedCards.map((card) => (
|
||||
<MotivatorCard
|
||||
key={card.id}
|
||||
card={card}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={nextStep}
|
||||
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'influence' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||
Évaluez l'influence de chaque motivation
|
||||
</h2>
|
||||
<p className="text-muted">
|
||||
Pour chaque carte, indiquez si cette motivation a une influence positive ou négative sur votre situation actuelle
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfluenceZone
|
||||
cards={sortedCards}
|
||||
onInfluenceChange={handleInfluenceChange}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<button
|
||||
onClick={nextStep}
|
||||
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Voir le récapitulatif →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'summary' && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||
Récapitulatif de vos Moving Motivators
|
||||
</h2>
|
||||
<p className="text-muted">
|
||||
Voici l'analyse de vos motivations et leur impact
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MotivatorSummary cards={sortedCards} />
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-start">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-card transition-colors"
|
||||
>
|
||||
← Modifier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIndicator({
|
||||
number,
|
||||
label,
|
||||
active,
|
||||
completed,
|
||||
onClick,
|
||||
}: {
|
||||
number: number;
|
||||
label: string;
|
||||
active: boolean;
|
||||
completed: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex flex-col items-center gap-1 transition-colors
|
||||
${active ? 'text-primary' : completed ? 'text-green-500' : 'text-muted'}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm
|
||||
${active ? 'bg-primary text-primary-foreground' : ''}
|
||||
${completed ? 'bg-green-500 text-white' : ''}
|
||||
${!active && !completed ? 'bg-muted/20 text-muted' : ''}
|
||||
`}
|
||||
>
|
||||
{completed ? '✓' : number}
|
||||
</div>
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
172
src/components/moving-motivators/MotivatorCard.tsx
Normal file
172
src/components/moving-motivators/MotivatorCard.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
|
||||
import { MOTIVATOR_BY_TYPE } from '@/lib/types';
|
||||
|
||||
interface MotivatorCardProps {
|
||||
card: MotivatorCardType;
|
||||
onInfluenceChange?: (influence: number) => void;
|
||||
disabled?: boolean;
|
||||
showInfluence?: boolean;
|
||||
}
|
||||
|
||||
export function MotivatorCard({
|
||||
card,
|
||||
disabled = false,
|
||||
showInfluence = false,
|
||||
}: MotivatorCardProps) {
|
||||
const config = MOTIVATOR_BY_TYPE[card.type];
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: card.id,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center
|
||||
w-28 h-36 rounded-xl border-2 shrink-0
|
||||
bg-card shadow-md
|
||||
cursor-grab active:cursor-grabbing
|
||||
transition-all duration-200
|
||||
${isDragging ? 'opacity-50 scale-105 shadow-xl z-50' : 'hover:shadow-lg hover:-translate-y-1'}
|
||||
${disabled ? 'cursor-default opacity-60' : ''}
|
||||
`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{/* Color accent bar */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-2 rounded-t-lg"
|
||||
style={{ backgroundColor: config.color }}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="text-3xl mb-1 mt-2">{config.icon}</div>
|
||||
|
||||
{/* Name */}
|
||||
<div
|
||||
className="font-semibold text-sm text-center px-2"
|
||||
style={{ color: config.color }}
|
||||
>
|
||||
{config.name}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[10px] text-muted text-center px-2 mt-1 line-clamp-2">
|
||||
{config.description}
|
||||
</p>
|
||||
|
||||
{/* Influence indicator */}
|
||||
{showInfluence && card.influence !== 0 && (
|
||||
<div
|
||||
className={`
|
||||
absolute -top-2 -right-2 w-6 h-6 rounded-full
|
||||
flex items-center justify-center text-xs font-bold text-white
|
||||
${card.influence > 0 ? 'bg-green-500' : 'bg-red-500'}
|
||||
`}
|
||||
>
|
||||
{card.influence > 0 ? `+${card.influence}` : card.influence}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank badge */}
|
||||
<div
|
||||
className="absolute -bottom-2 left-1/2 -translate-x-1/2
|
||||
bg-foreground text-background text-xs font-bold
|
||||
w-5 h-5 rounded-full flex items-center justify-center"
|
||||
>
|
||||
{card.orderIndex}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-draggable version for summary
|
||||
export function MotivatorCardStatic({
|
||||
card,
|
||||
size = 'normal',
|
||||
}: {
|
||||
card: MotivatorCardType;
|
||||
size?: 'small' | 'normal';
|
||||
}) {
|
||||
const config = MOTIVATOR_BY_TYPE[card.type];
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'w-20 h-24 text-2xl',
|
||||
normal: 'w-28 h-36 text-3xl',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center
|
||||
rounded-xl border-2 bg-card shadow-md
|
||||
${sizeClasses[size]}
|
||||
`}
|
||||
>
|
||||
{/* Color accent bar */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-2 rounded-t-lg"
|
||||
style={{ backgroundColor: config.color }}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={`mb-1 mt-2 ${size === 'small' ? 'text-xl' : 'text-3xl'}`}>
|
||||
{config.icon}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div
|
||||
className={`font-semibold text-center px-2 ${size === 'small' ? 'text-xs' : 'text-sm'}`}
|
||||
style={{ color: config.color }}
|
||||
>
|
||||
{config.name}
|
||||
</div>
|
||||
|
||||
{/* Influence indicator */}
|
||||
{card.influence !== 0 && (
|
||||
<div
|
||||
className={`
|
||||
absolute -top-2 -right-2 rounded-full
|
||||
flex items-center justify-center font-bold text-white
|
||||
${card.influence > 0 ? 'bg-green-500' : 'bg-red-500'}
|
||||
${size === 'small' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'}
|
||||
`}
|
||||
>
|
||||
{card.influence > 0 ? `+${card.influence}` : card.influence}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank badge */}
|
||||
<div
|
||||
className={`
|
||||
absolute -bottom-2 left-1/2 -translate-x-1/2
|
||||
bg-foreground text-background font-bold
|
||||
rounded-full flex items-center justify-center
|
||||
${size === 'small' ? 'w-4 h-4 text-[10px]' : 'w-5 h-5 text-xs'}
|
||||
`}
|
||||
>
|
||||
{card.orderIndex}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
138
src/components/moving-motivators/MotivatorLiveWrapper.tsx
Normal file
138
src/components/moving-motivators/MotivatorLiveWrapper.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useMotivatorLive, type MotivatorLiveEvent } from '@/hooks/useMotivatorLive';
|
||||
import { LiveIndicator } from '@/components/collaboration/LiveIndicator';
|
||||
import { MotivatorShareModal } from './MotivatorShareModal';
|
||||
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 MotivatorLiveWrapperProps {
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
currentUserId: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
canEdit: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MotivatorLiveWrapper({
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
currentUserId,
|
||||
shares,
|
||||
isOwner,
|
||||
canEdit,
|
||||
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) => (
|
||||
<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 */}
|
||||
<MotivatorShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
sessionId={sessionId}
|
||||
sessionTitle={sessionTitle}
|
||||
shares={shares}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
180
src/components/moving-motivators/MotivatorShareModal.tsx
Normal file
180
src/components/moving-motivators/MotivatorShareModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'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 { shareMotivatorSession, removeMotivatorShare } from '@/actions/moving-motivators';
|
||||
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 MotivatorShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
shares: Share[];
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export function MotivatorShareModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
sessionId,
|
||||
sessionTitle,
|
||||
shares,
|
||||
isOwner,
|
||||
}: MotivatorShareModalProps) {
|
||||
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 shareMotivatorSession(sessionId, email, role);
|
||||
if (result.success) {
|
||||
setEmail('');
|
||||
} else {
|
||||
setError(result.error || 'Erreur lors du partage');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRemove(userId: string) {
|
||||
startTransition(async () => {
|
||||
await removeMotivatorShare(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 Moving Motivators</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 cartes et leurs positions
|
||||
<br />
|
||||
<strong>Lecteur</strong> : peut uniquement consulter
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
103
src/components/moving-motivators/MotivatorSummary.tsx
Normal file
103
src/components/moving-motivators/MotivatorSummary.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import type { MotivatorCard as MotivatorCardType } from '@/lib/types';
|
||||
import { MotivatorCardStatic } from './MotivatorCard';
|
||||
|
||||
interface MotivatorSummaryProps {
|
||||
cards: MotivatorCardType[];
|
||||
}
|
||||
|
||||
export function MotivatorSummary({ cards }: MotivatorSummaryProps) {
|
||||
// Sort by orderIndex (importance)
|
||||
const sortedByImportance = [...cards].sort((a, b) => a.orderIndex - b.orderIndex);
|
||||
|
||||
// Top 3 most important (highest orderIndex)
|
||||
const top3 = sortedByImportance.slice(-3).reverse();
|
||||
|
||||
// Bottom 3 least important (lowest orderIndex)
|
||||
const bottom3 = sortedByImportance.slice(0, 3);
|
||||
|
||||
// Cards with positive influence
|
||||
const positiveInfluence = cards.filter((c) => c.influence > 0).sort((a, b) => b.influence - a.influence);
|
||||
|
||||
// Cards with negative influence
|
||||
const negativeInfluence = cards.filter((c) => c.influence < 0).sort((a, b) => a.influence - b.influence);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Top 3 Most Important */}
|
||||
<SummarySection
|
||||
title="🏆 Top 3 - Plus importantes"
|
||||
subtitle="Ces motivations vous animent le plus"
|
||||
cards={top3}
|
||||
emptyMessage="Classez vos cartes pour voir ce résultat"
|
||||
variant="success"
|
||||
/>
|
||||
|
||||
{/* Bottom 3 Least Important */}
|
||||
<SummarySection
|
||||
title="📉 Moins importantes"
|
||||
subtitle="Ces motivations ont moins d'impact pour vous"
|
||||
cards={bottom3}
|
||||
emptyMessage="Classez vos cartes pour voir ce résultat"
|
||||
variant="muted"
|
||||
/>
|
||||
|
||||
{/* Positive Influence */}
|
||||
<SummarySection
|
||||
title="✨ Influence positive"
|
||||
subtitle="Ces motivations sont satisfaites actuellement"
|
||||
cards={positiveInfluence}
|
||||
emptyMessage="Aucune motivation en influence positive"
|
||||
variant="success"
|
||||
/>
|
||||
|
||||
{/* Negative Influence */}
|
||||
<SummarySection
|
||||
title="⚠️ Influence négative"
|
||||
subtitle="Ces motivations ne sont pas satisfaites"
|
||||
cards={negativeInfluence}
|
||||
emptyMessage="Aucune motivation en influence négative"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummarySection({
|
||||
title,
|
||||
subtitle,
|
||||
cards,
|
||||
emptyMessage,
|
||||
variant,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cards: MotivatorCardType[];
|
||||
emptyMessage: string;
|
||||
variant: 'success' | 'danger' | 'muted';
|
||||
}) {
|
||||
const borderColors = {
|
||||
success: 'border-green-500/30 bg-green-500/5',
|
||||
danger: 'border-red-500/30 bg-red-500/5',
|
||||
muted: 'border-border bg-muted/5',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border-2 p-5 ${borderColors[variant]}`}>
|
||||
<h3 className="font-semibold text-foreground mb-1">{title}</h3>
|
||||
<p className="text-sm text-muted mb-4">{subtitle}</p>
|
||||
|
||||
{cards.length > 0 ? (
|
||||
<div className="flex gap-3 flex-wrap justify-center">
|
||||
{cards.map((card) => (
|
||||
<MotivatorCardStatic key={card.id} card={card} size="small" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted text-center py-4">{emptyMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
7
src/components/moving-motivators/index.ts
Normal file
7
src/components/moving-motivators/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { MotivatorBoard } from './MotivatorBoard';
|
||||
export { MotivatorCard, MotivatorCardStatic } from './MotivatorCard';
|
||||
export { MotivatorSummary } from './MotivatorSummary';
|
||||
export { InfluenceZone } from './InfluenceZone';
|
||||
export { MotivatorLiveWrapper } from './MotivatorLiveWrapper';
|
||||
export { MotivatorShareModal } from './MotivatorShareModal';
|
||||
|
||||
Reference in New Issue
Block a user