From 7eb77dd782aeef6b9f585348021ef79397276666 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 16:13:32 -0500 Subject: [PATCH] feat: add ShareSessionModal and integrate into SessionDetailPage Co-Authored-By: Claude Opus 4.6 --- .../components/session/ShareSessionModal.tsx | 431 ++++++++++++++++++ frontend/src/pages/SessionDetailPage.tsx | 215 ++------- 2 files changed, 465 insertions(+), 181 deletions(-) create mode 100644 frontend/src/components/session/ShareSessionModal.tsx diff --git a/frontend/src/components/session/ShareSessionModal.tsx b/frontend/src/components/session/ShareSessionModal.tsx new file mode 100644 index 00000000..89f9501e --- /dev/null +++ b/frontend/src/components/session/ShareSessionModal.tsx @@ -0,0 +1,431 @@ +import { useState, useEffect } from 'react' +import { X, Copy, Check, Globe, Users, Clock, Trash2, Link2 } from 'lucide-react' +import type { SessionShare, SessionShareVisibility } from '@/types' +import { sessionsApi } from '@/api/sessions' +import { buildSessionShareUrl, filterSharesForSession } from '@/lib/sessionShare' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' + +interface ShareSessionModalProps { + sessionId: string + sessionLabel: string // e.g. ticket number or "Session Details" + isOpen: boolean + onClose: () => void +} + +type ExpirationPreset = 'never' | '1day' | '7days' | '30days' | 'custom' + +function getRelativeTime(dateString: string): string { + const now = Date.now() + const date = new Date(dateString).getTime() + const diffMs = now - date + const diffSeconds = Math.floor(diffMs / 1000) + const diffMinutes = Math.floor(diffSeconds / 60) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSeconds < 60) return 'just now' + if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago` + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago` + if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago` + const diffMonths = Math.floor(diffDays / 30) + return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago` +} + +function getExpirationLabel(expiresAt: string | null): { text: string; isExpired: boolean } { + if (!expiresAt) return { text: 'No expiration', isExpired: false } + const now = Date.now() + const expiry = new Date(expiresAt).getTime() + if (expiry <= now) return { text: 'Expired', isExpired: true } + + const diffMs = expiry - now + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffHours / 24) + + if (diffDays > 0) return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false } + if (diffHours > 0) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false } + return { text: 'Expires soon', isExpired: false } +} + +function computeExpiresAt(preset: ExpirationPreset, customDatetime: string): string | undefined { + if (preset === 'never') return undefined + if (preset === 'custom') { + if (!customDatetime) return undefined + return new Date(customDatetime).toISOString() + } + + const now = new Date() + switch (preset) { + case '1day': + now.setDate(now.getDate() + 1) + break + case '7days': + now.setDate(now.getDate() + 7) + break + case '30days': + now.setDate(now.getDate() + 30) + break + } + return now.toISOString() +} + +export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }: ShareSessionModalProps) { + const [shares, setShares] = useState([]) + const [isLoadingShares, setIsLoadingShares] = useState(false) + const [isGenerating, setIsGenerating] = useState(false) + const [copiedShareId, setCopiedShareId] = useState(null) + + // Form state + const [visibility, setVisibility] = useState('account') + const [shareName, setShareName] = useState('') + const [expirationPreset, setExpirationPreset] = useState('never') + const [customDatetime, setCustomDatetime] = useState('') + const [visibilityError, setVisibilityError] = useState(null) + + useEffect(() => { + if (isOpen) { + loadShares() + // Reset form state + setVisibility('account') + setShareName('') + setExpirationPreset('never') + setCustomDatetime('') + setVisibilityError(null) + setCopiedShareId(null) + } + }, [isOpen, sessionId]) + + const loadShares = async () => { + setIsLoadingShares(true) + try { + const allShares = await sessionsApi.listMyShares() + const sessionShares = filterSharesForSession(allShares, sessionId) + // Sort newest first + sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + setShares(sessionShares) + } catch (err) { + console.error('Failed to load shares:', err) + } finally { + setIsLoadingShares(false) + } + } + + const handleGenerateLink = async () => { + setIsGenerating(true) + setVisibilityError(null) + try { + const expires_at = computeExpiresAt(expirationPreset, customDatetime) + const newShare = await sessionsApi.createShare(sessionId, { + visibility, + share_name: shareName.trim() || undefined, + expires_at, + }) + setShares([newShare, ...shares]) + toast.success('Share link generated') + // Reset form + setShareName('') + setExpirationPreset('never') + setCustomDatetime('') + } catch (err: unknown) { + const error = err as { response?: { status?: number; data?: { detail?: string } } } + if ( + error.response?.status === 403 && + error.response?.data?.detail?.toLowerCase().includes('public session sharing') + ) { + setVisibilityError(error.response.data.detail ?? 'Organization does not allow public session sharing') + } else { + console.error('Failed to generate share link:', err) + toast.error('Failed to generate share link') + } + } finally { + setIsGenerating(false) + } + } + + const handleCopyUrl = async (share: SessionShare) => { + try { + const url = buildSessionShareUrl(share) + await navigator.clipboard.writeText(url) + setCopiedShareId(share.id) + toast.success('Link copied to clipboard') + setTimeout(() => setCopiedShareId(null), 2000) + } catch (err) { + console.error('Failed to copy link:', err) + toast.error('Failed to copy link') + } + } + + const handleRevoke = async (shareId: string) => { + try { + await sessionsApi.revokeShare(shareId) + setShares(shares.filter((s) => s.id !== shareId)) + toast.success('Share link revoked') + } catch (err) { + console.error('Failed to revoke share:', err) + toast.error('Failed to revoke share') + } + } + + if (!isOpen) return null + + const presetButtons: { value: ExpirationPreset; label: string }[] = [ + { value: 'never', label: 'Never' }, + { value: '1day', label: '1 day' }, + { value: '7days', label: '7 days' }, + { value: '30days', label: '30 days' }, + { value: 'custom', label: 'Custom' }, + ] + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

Share Session

+

{sessionLabel}

+
+ +
+ + {/* Body */} +
+ {/* Create Share Form */} +
+ {/* Visibility */} +
+ +
+ + +
+ {visibilityError && ( +

{visibilityError}

+ )} +
+ + {/* Share Name */} +
+ + setShareName(e.target.value.slice(0, 100))} + placeholder="e.g. Training link, Customer escalation" + className={cn( + 'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder-white/30', + 'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20' + )} + maxLength={100} + /> +
+ + {/* Expiration */} +
+ +
+ {presetButtons.map((preset) => ( + + ))} +
+ {expirationPreset === 'custom' && ( + setCustomDatetime(e.target.value)} + className={cn( + 'mt-2 w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white', + 'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20', + '[color-scheme:dark]' + )} + /> + )} +
+ + {/* Generate Button */} + +
+ + {/* Existing Shares */} + {shares.length > 0 && ( +
+

+ Active Shares ({shares.length}) +

+
+ {shares.map((share) => { + const expiration = getExpirationLabel(share.expires_at) + const isCopied = copiedShareId === share.id + return ( +
+
+
+
+ + {share.visibility === 'public' ? ( + + ) : ( + + )} + {share.visibility === 'public' ? 'Public' : 'Account'} + + + {share.share_name || 'Untitled share'} + +
+
+ {getRelativeTime(share.created_at)} + + {share.view_count > 0 + ? `${share.view_count} view${share.view_count === 1 ? '' : 's'}` + : 'Not viewed yet'} + + + + {expiration.text} + +
+
+
+ + +
+
+
+ ) + })} +
+
+ )} + + {/* Loading state */} + {isLoadingShares && shares.length === 0 && ( +
+
+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ) +} + +export default ShareSessionModal diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx index 6adc2f5b..0f1cd966 100644 --- a/frontend/src/pages/SessionDetailPage.tsx +++ b/frontend/src/pages/SessionDetailPage.tsx @@ -1,11 +1,15 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { Copy, Check, Eye, Save } from 'lucide-react' +import { Copy, Check, Eye, Save, Share2 } from 'lucide-react' import { sessionsApi } from '@/api/sessions' import { stepsApi } from '@/api/steps' import { ExportPreviewModal } from '@/components/session/ExportPreviewModal' import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal' +import { ShareSessionModal } from '@/components/session/ShareSessionModal' +import { SessionTimeline } from '@/components/session/SessionTimeline' import { StepRatingModal } from '@/components/session/StepRatingModal' +import { ActionMenu } from '@/components/common/ActionMenu' +import type { MenuAction } from '@/components/common/ActionMenu' import { useUserPreferencesStore } from '@/store/userPreferencesStore' import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types' import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings' @@ -30,7 +34,7 @@ export function SessionDetailPage() { const [showRatingModal, setShowRatingModal] = useState(false) const [isSavingRatings, setIsSavingRatings] = useState(false) const [librarySteps, setLibrarySteps] = useState([]) - const [copiedStepIndex, setCopiedStepIndex] = useState(null) + const [showShareModal, setShowShareModal] = useState(false) const [maxStepIndex, setMaxStepIndex] = useState(null) const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard') const [includeSummary, setIncludeSummary] = useState(false) @@ -257,26 +261,6 @@ export function SessionDetailPage() { } } - const handleCopyStep = async (decision: Session['decisions'][number], index: number) => { - const lines: string[] = [] - if (decision.question) lines.push(`Question: ${decision.question}`) - if (decision.answer) lines.push(`Answer: ${decision.answer}`) - if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`) - if (decision.notes) lines.push(`Notes: ${decision.notes}`) - if (decision.command_output) lines.push(`Output:\n${decision.command_output}`) - try { - await navigator.clipboard.writeText(lines.join('\n')) - setCopiedStepIndex(index) - setTimeout(() => setCopiedStepIndex(null), 2000) - } catch { - // Clipboard access denied - } - } - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString() - } - const formatDuration = (durationSeconds: number | null | undefined) => { if (durationSeconds == null || durationSeconds < 0) return null if (durationSeconds < 60) return `${durationSeconds}s` @@ -381,20 +365,20 @@ export function SessionDetailPage() { {/* Actions */}
- {/* Save as Tree - Only for completed sessions */} - {session.completed_at && ( - - )} + setShowShareModal(true), + }, + ...(session.completed_at ? [{ + label: 'Save as Tree', + icon: Save, + onClick: () => setShowSaveAsTreeModal(true), + }] as MenuAction[] : []), + ]} + /> {/* Copy for Ticket */} -
-
- ) - })} - {session.completed_at && ( -
- - - Procedure completed: {formatDate(session.completed_at)} - -
- )} -
- - ) : ( - <> -

Decision Timeline

-
-
- - - Session started: {formatDate(session.started_at)} - -
- - {session.decisions.map((decision, index) => ( -
-
- -
-
-
- {decision.question && ( -

{decision.question}

- )} - {decision.answer && ( -

Answer: {decision.answer}

- )} - {decision.action_performed && ( -

- Action: {decision.action_performed} -

- )} - {decision.notes && ( -

- Notes: {decision.notes} -

- )} - {decision.command_output && ( -
-

Command Output

-
-                                {decision.command_output}
-                              
-
- )} - {decision.duration_seconds != null && ( -

- Duration: {formatDuration(decision.duration_seconds)} -

- )} -

- {formatDate(decision.timestamp)} -

-
- -
-
-
-
- ))} - - {session.completed_at && ( -
- - - Session completed: {formatDate(session.completed_at)} - -
- )} -
- - )} - + ).tree_type as string} + startedAt={session.started_at} + completedAt={session.completed_at} + /> {/* Export Preview Modal */} + + {/* Share Session Modal */} + setShowShareModal(false)} + /> ) }