feat: add My Shares management page with nav link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-14 16:23:27 -05:00
parent d7641f2f84
commit d9734a11bf
3 changed files with 250 additions and 0 deletions

View File

@@ -84,6 +84,7 @@ export function AppLayout() {
},
{ path: '/my-trees', label: 'My Flows' },
{ path: '/sessions', label: 'Sessions' },
{ path: '/shares', label: 'My Shares' },
{ path: '/account', label: 'Account' },
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
]

View File

@@ -0,0 +1,240 @@
import { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
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 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 (share: SessionShare) => {
const confirmed = window.confirm(
'Revoke this share link? Anyone with the link will no longer be able to access the session.'
)
if (!confirmed) return
try {
await sessionsApi.revokeShare(share.id)
setShares((prev) => prev.filter((s) => s.id !== share.id))
toast.success('Share link revoked')
} catch {
toast.error('Failed to revoke share link')
}
}
// Loading state
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
</div>
)
}
// Error state
if (error) {
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="glass-card rounded-xl p-6 border border-red-400/20">
<div className="text-center">
<p className="text-red-400 text-sm mb-4">{error}</p>
<button
onClick={fetchShares}
className="bg-white text-black hover:bg-white/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
>
Try again
</button>
</div>
</div>
</div>
)
}
return (
<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-white/40 hover:text-white/70 transition-colors mb-6"
>
<ArrowLeft className="h-4 w-4" />
Back to sessions
</Link>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">My Shared Sessions</h1>
<p className="text-white/40 mt-1">Manage your session share links</p>
</div>
{/* Empty state */}
{shares.length === 0 ? (
<div className="glass-card rounded-xl p-12 text-center">
<Link2 className="h-12 w-12 text-white/20 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-white mb-2">No shared sessions</h2>
<p className="text-white/40 text-sm mb-6">
Share a session from the session detail page to create a link
</p>
<button
onClick={() => navigate('/sessions')}
className="bg-white text-black hover:bg-white/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
>
Go to Sessions
</button>
</div>
) : (
<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="glass-card 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-white/10 text-white/60">
{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-white">
{share.share_name || 'Untitled share'}
</span>
</div>
{/* Session info */}
<p className="text-sm text-white/50 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-white/40 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
onClick={() => handleCopyLink(share)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
isCopied
? 'bg-emerald-400/10 text-emerald-400'
: 'bg-white text-black hover:bg-white/90'
)}
>
{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-white/10 text-white/60 hover:bg-white/10 rounded-md px-3 py-1.5 text-sm transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
View Session
</Link>
<button
onClick={() => handleRevoke(share)}
className="inline-flex items-center gap-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-md px-3 py-1.5 text-sm transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Revoke
</button>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -26,6 +26,7 @@ const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage'))
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
// Admin pages
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
@@ -189,6 +190,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'shares',
element: (
<Suspense fallback={<PageLoader />}>
<MySharesPage />
</Suspense>
),
},
// Admin routes
{
path: 'admin',