diff --git a/frontend/src/components/common/StaggerList.tsx b/frontend/src/components/common/StaggerList.tsx new file mode 100644 index 00000000..1e3444df --- /dev/null +++ b/frontend/src/components/common/StaggerList.tsx @@ -0,0 +1,43 @@ +import { Children, type ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface StaggerListProps { + children: ReactNode + className?: string + /** Base delay before the first item animates (ms). Default: 0 */ + baseDelay?: number + /** Delay increment between each item (ms). Default: 50 */ + staggerMs?: number +} + +/** + * Wraps each child in a stagger-item animation container. + * Children fade in sequentially with a configurable delay. + * + * Usage: + * + * {items.map(item => )} + * + */ +export function StaggerList({ + children, + className, + baseDelay = 0, + staggerMs = 50, +}: StaggerListProps) { + const items = Children.toArray(children) + + return ( +
+ {items.map((child, i) => ( +
+ {child} +
+ ))} +
+ ) +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index d56ddcfb..61c02c24 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback } from 'react' -import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom' +import { useLocation, useNavigate, Link } from 'react-router-dom' import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield } from 'lucide-react' import { useAuthStore } from '@/store/authStore' import { usePermissions } from '@/hooks/usePermissions' @@ -8,6 +8,7 @@ import { BrandLogo } from '@/components/common/BrandLogo' import { TopBar } from './TopBar' import { Sidebar } from './Sidebar' import { EmailVerificationBanner } from './EmailVerificationBanner' +import { ViewTransitionOutlet } from './ViewTransitionOutlet' import { cn } from '@/lib/utils' export function AppLayout() { @@ -185,7 +186,7 @@ export function AppLayout() { {/* Main Content */}
- +
diff --git a/frontend/src/components/layout/ViewTransitionOutlet.tsx b/frontend/src/components/layout/ViewTransitionOutlet.tsx new file mode 100644 index 00000000..862c2619 --- /dev/null +++ b/frontend/src/components/layout/ViewTransitionOutlet.tsx @@ -0,0 +1,21 @@ +import { Outlet, useLocation } from 'react-router-dom' + +/** + * Wraps with a fade-in animation keyed to the route path. + * When the route changes, React unmounts/remounts the wrapper div, + * triggering the CSS animation. Sidebar and topbar stay still. + */ +export function ViewTransitionOutlet() { + const location = useLocation() + + // Use the first two path segments as the key to avoid re-animating + // on param changes within the same page (e.g., /trees/123 → /trees/456) + const segments = location.pathname.split('/').filter(Boolean) + const routeKey = segments.slice(0, 2).join('/') || '/' + + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/library/TreeGridView.tsx b/frontend/src/components/library/TreeGridView.tsx index 3fb34531..2792c905 100644 --- a/frontend/src/components/library/TreeGridView.tsx +++ b/frontend/src/components/library/TreeGridView.tsx @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom' import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react' import type { TreeListItem } from '@/types' import { TagBadges } from '@/components/common/TagBadges' +import { StaggerList } from '@/components/common/StaggerList' import { cn } from '@/lib/utils' import { usePermissions } from '@/hooks/usePermissions' import { getTreeEditorPath } from '@/lib/routing' @@ -33,7 +34,7 @@ export function TreeGridView({ const { canEditTree, canDeleteTree } = usePermissions() return ( -
+ {trees.map((tree) => (
))} -
+ ) } diff --git a/frontend/src/index.css b/frontend/src/index.css index bdd0bbe7..360dcb53 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -147,6 +147,11 @@ 80% { transform: rotate(-2deg); } 100% { transform: rotate(0deg); } } + + @keyframes stagger-fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } } /* ── Root CSS variables (non-theme: glass, shadows, layout) ── */ @@ -249,6 +254,12 @@ animation: breatheGlow 3s ease-in-out infinite alternate; } +@utility stagger-item { + opacity: 0; + animation: stagger-fade-in 350ms var(--ease-out-smooth) forwards; + animation-delay: calc(var(--stagger-index, 0) * 50ms); +} + @utility rdp-custom { @apply text-foreground; & .rdp-month { @apply w-full; } diff --git a/frontend/src/pages/MyTreesPage.tsx b/frontend/src/pages/MyTreesPage.tsx index feb8e50b..d65b2094 100644 --- a/frontend/src/pages/MyTreesPage.tsx +++ b/frontend/src/pages/MyTreesPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useNavigate, Link } from 'react-router-dom' import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react' import { PageMeta } from '@/components/common/PageMeta' +import { StaggerList } from '@/components/common/StaggerList' import { Button } from '@/components/ui/Button' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' @@ -217,7 +218,7 @@ export function MyTreesPage() { ) : ( -
+ {trees.map((tree) => (
))} -
+ )} {/* Delete Confirmation */} diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx index e8471f7e..f205a6be 100644 --- a/frontend/src/pages/SessionHistoryPage.tsx +++ b/frontend/src/pages/SessionHistoryPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { PageMeta } from '@/components/common/PageMeta' +import { StaggerList } from '@/components/common/StaggerList' import { sessionsApi } from '@/api/sessions' import { treesApi } from '@/api/trees' import type { Session, TreeListItem } from '@/types' @@ -213,7 +214,8 @@ export function SessionHistoryPage() { } /> ) : ( -
+ <> + {sessions.map((session) => (
))} - {hasMore ? ( -

- Showing the 50 most recent sessions -

- ) : sessions.length > 0 ? ( -

- Showing all {sessions.length} sessions -

- ) : null} -
+ + {hasMore ? ( +

+ Showing the 50 most recent sessions +

+ ) : sessions.length > 0 ? ( +

+ Showing all {sessions.length} sessions +

+ ) : null} + )}