- TreeLibraryPage: split empty state into no-flows (illustration + CTA) vs no-filter-results - MyAnalyticsPage/TeamAnalyticsPage: add zero-sessions empty state with illustration - SessionHistoryPage: split into no-sessions (illustration) vs no-filter-results - StepLibraryBrowser: illustrative empty state when no steps exist - ScriptTemplateList: replace plain empty state with ScriptIllustration - MySharesPage: replace icon-based empty state with ShareIllustration - IntegrationsPage: add IntegrationIllustration above setup form - Add script-templates and psa-setup guides to guides data - Add EmptyState vitest tests (7 tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
250 lines
9.1 KiB
TypeScript
250 lines
9.1 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
import { Globe, Users, Copy, Check, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
|
|
import { PageMeta } from '@/components/common/PageMeta'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
import { EmptyState } from '@/components/common/EmptyState'
|
|
import { ShareIllustration } from '@/components/common/EmptyStateIllustrations'
|
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
import { sessionsApi } from '@/api/sessions'
|
|
import { buildSessionShareUrl } from '@/lib/sessionShare'
|
|
import type { SessionShare } from '@/types'
|
|
|
|
function formatRelativeTime(dateString: string): string {
|
|
const date = new Date(dateString)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMinutes = Math.floor(diffMs / 60000)
|
|
const diffHours = Math.floor(diffMs / 3600000)
|
|
const diffDays = Math.floor(diffMs / 86400000)
|
|
|
|
if (diffMinutes < 1) return 'just now'
|
|
if (diffHours < 1) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`
|
|
if (diffDays < 1) 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 formatExpiration(expiresAt: string | null): { text: string; isExpired: boolean } {
|
|
if (!expiresAt) return { text: 'No expiration', isExpired: false }
|
|
|
|
const expiry = new Date(expiresAt)
|
|
const now = new Date()
|
|
const diffMs = expiry.getTime() - now.getTime()
|
|
|
|
if (diffMs <= 0) return { text: 'Expired', isExpired: true }
|
|
|
|
const diffMinutes = Math.floor(diffMs / 60000)
|
|
const diffHours = Math.floor(diffMs / 3600000)
|
|
const diffDays = Math.floor(diffMs / 86400000)
|
|
|
|
if (diffHours < 1) return { text: `Expires in ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}`, isExpired: false }
|
|
if (diffDays < 1) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false }
|
|
return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false }
|
|
}
|
|
|
|
export default function MySharesPage() {
|
|
const navigate = useNavigate()
|
|
const [shares, setShares] = useState<SessionShare[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [copiedId, setCopiedId] = useState<string | null>(null)
|
|
const [revokeTarget, setRevokeTarget] = useState<SessionShare | null>(null)
|
|
|
|
const fetchShares = useCallback(async () => {
|
|
try {
|
|
setLoading(true)
|
|
setError(null)
|
|
const data = await sessionsApi.listMyShares()
|
|
setShares(data)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load shares')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetchShares()
|
|
}, [fetchShares])
|
|
|
|
const handleCopyLink = async (share: SessionShare) => {
|
|
try {
|
|
const url = buildSessionShareUrl(share)
|
|
await navigator.clipboard.writeText(url)
|
|
setCopiedId(share.id)
|
|
toast.success('Link copied')
|
|
setTimeout(() => setCopiedId(null), 2000)
|
|
} catch {
|
|
toast.error('Failed to copy link')
|
|
}
|
|
}
|
|
|
|
const handleRevoke = async () => {
|
|
if (!revokeTarget) return
|
|
try {
|
|
await sessionsApi.revokeShare(revokeTarget.id)
|
|
setShares((prev) => prev.filter((s) => s.id !== revokeTarget.id))
|
|
toast.success('Share link revoked')
|
|
} catch {
|
|
toast.error('Failed to revoke share link')
|
|
} finally {
|
|
setRevokeTarget(null)
|
|
}
|
|
}
|
|
|
|
// Loading state
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-32">
|
|
<Spinner />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Error state
|
|
if (error) {
|
|
return (
|
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
|
<div className="bg-card border border-red-400/20 rounded-xl p-6">
|
|
<div className="text-center">
|
|
<p className="text-red-400 text-sm mb-4">{error}</p>
|
|
<Button onClick={fetchShares}>
|
|
Try again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-y-auto h-full">
|
|
<PageMeta title="My Shares" />
|
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
|
{/* Back link */}
|
|
<Link
|
|
to="/sessions"
|
|
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mb-6"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back to sessions
|
|
</Link>
|
|
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-heading font-bold text-foreground">My Shared Sessions</h1>
|
|
<p className="text-muted-foreground mt-1">Manage your session share links</p>
|
|
</div>
|
|
|
|
{/* Empty state */}
|
|
{shares.length === 0 ? (
|
|
<EmptyState
|
|
illustration={<ShareIllustration />}
|
|
title="Share session results with your team"
|
|
description="Create shareable links to completed sessions for knowledge sharing and client communication."
|
|
action={
|
|
<Button onClick={() => navigate('/sessions')}>
|
|
View Sessions
|
|
</Button>
|
|
}
|
|
learnMoreLink="/guides/sharing-exports"
|
|
/>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{shares.map((share) => {
|
|
const expiration = formatExpiration(share.expires_at)
|
|
const isCopied = copiedId === share.id
|
|
|
|
return (
|
|
<div key={share.id} className="bg-card border border-border rounded-xl p-5">
|
|
{/* Top row: badge + name */}
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<span className="inline-flex items-center gap-1.5 text-xs rounded-full px-2 py-0.5 bg-accent text-muted-foreground">
|
|
{share.visibility === 'public' ? (
|
|
<Globe className="h-3 w-3" />
|
|
) : (
|
|
<Users className="h-3 w-3" />
|
|
)}
|
|
{share.visibility === 'public' ? 'Public' : 'Account Only'}
|
|
</span>
|
|
<span className="text-sm font-medium text-foreground">
|
|
{share.share_name || 'Untitled share'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Session info */}
|
|
<p className="text-sm text-muted-foreground mb-2">
|
|
Session ID: {share.session_id.slice(0, 8)}...
|
|
</p>
|
|
|
|
{/* Meta row */}
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground mb-4">
|
|
<span>Created {formatRelativeTime(share.created_at)}</span>
|
|
<span className="hidden sm:inline">·</span>
|
|
<span>
|
|
{share.view_count > 0
|
|
? `${share.view_count} view${share.view_count === 1 ? '' : 's'}`
|
|
: 'Not viewed yet'}
|
|
</span>
|
|
<span className="hidden sm:inline">·</span>
|
|
<span className={cn(expiration.isExpired && 'text-red-400')}>
|
|
{expiration.text}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleCopyLink(share)}
|
|
className={isCopied ? 'bg-emerald-400/10 text-emerald-400 shadow-none hover:opacity-100' : ''}
|
|
>
|
|
{isCopied ? (
|
|
<Check className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Copy className="h-3.5 w-3.5" />
|
|
)}
|
|
{isCopied ? 'Copied' : 'Copy Link'}
|
|
</Button>
|
|
|
|
<Link
|
|
to={`/sessions/${share.session_id}`}
|
|
className="inline-flex items-center gap-1.5 border border-border text-muted-foreground hover:bg-accent rounded-md px-3 py-1.5 text-sm transition-colors"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
View Session
|
|
</Link>
|
|
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setRevokeTarget(share)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Revoke
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
isOpen={!!revokeTarget}
|
|
onClose={() => setRevokeTarget(null)}
|
|
onConfirm={handleRevoke}
|
|
title="Revoke Share Link"
|
|
message="Revoke this share link? Anyone with the link will no longer be able to access the session."
|
|
confirmLabel="Revoke"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|