From d9734a11bf5027a57cdeb914af834a9980115b91 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 14 Feb 2026 16:23:27 -0500 Subject: [PATCH] feat: add My Shares management page with nav link Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/layout/AppLayout.tsx | 1 + frontend/src/pages/MySharesPage.tsx | 240 +++++++++++++++++++ frontend/src/router.tsx | 9 + 3 files changed, 250 insertions(+) create mode 100644 frontend/src/pages/MySharesPage.tsx diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 5240506c..8ee7639f 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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' }] : []), ] diff --git a/frontend/src/pages/MySharesPage.tsx b/frontend/src/pages/MySharesPage.tsx new file mode 100644 index 00000000..31c34c57 --- /dev/null +++ b/frontend/src/pages/MySharesPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [copiedId, setCopiedId] = useState(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 ( +
+
+
+ ) + } + + // Error state + if (error) { + return ( +
+
+
+

{error}

+ +
+
+
+ ) + } + + return ( +
+ {/* Back link */} + + + Back to sessions + + + {/* Header */} +
+

My Shared Sessions

+

Manage your session share links

+
+ + {/* Empty state */} + {shares.length === 0 ? ( +
+ +

No shared sessions

+

+ Share a session from the session detail page to create a link +

+ +
+ ) : ( +
+ {shares.map((share) => { + const expiration = formatExpiration(share.expires_at) + const isCopied = copiedId === share.id + + return ( +
+ {/* Top row: badge + name */} +
+ + {share.visibility === 'public' ? ( + + ) : ( + + )} + {share.visibility === 'public' ? 'Public' : 'Account Only'} + + + {share.share_name || 'Untitled share'} + +
+ + {/* Session info */} +

+ Session ID: {share.session_id.slice(0, 8)}... +

+ + {/* Meta row */} +
+ Created {formatRelativeTime(share.created_at)} + · + + {share.view_count > 0 + ? `${share.view_count} view${share.view_count === 1 ? '' : 's'}` + : 'Not viewed yet'} + + · + + {expiration.text} + +
+ + {/* Actions */} +
+ + + + + View Session + + + +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index f8bbce1a..9d6b3232 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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([ ), }, + { + path: 'shares', + element: ( + }> + + + ), + }, // Admin routes { path: 'admin',