feat: add My Shares management page with nav link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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' }] : []),
|
||||
]
|
||||
|
||||
240
frontend/src/pages/MySharesPage.tsx
Normal file
240
frontend/src/pages/MySharesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user