feat: add page transitions and staggered list animations
- ViewTransitionOutlet: wraps Outlet with fade-in-up animation keyed to route path. Sidebar/topbar stay still, only content area animates. - StaggerList: reusable component that cascades children with incremental delay (50ms default). Pure CSS via @utility stagger-item. - Applied stagger to TreeGridView, MyTreesPage cards, SessionHistoryPage. - New stagger-fade-in keyframe in @theme block. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
43
frontend/src/components/common/StaggerList.tsx
Normal file
43
frontend/src/components/common/StaggerList.tsx
Normal file
@@ -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:
|
||||||
|
* <StaggerList className="grid grid-cols-3 gap-4">
|
||||||
|
* {items.map(item => <Card key={item.id} ... />)}
|
||||||
|
* </StaggerList>
|
||||||
|
*/
|
||||||
|
export function StaggerList({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
baseDelay = 0,
|
||||||
|
staggerMs = 50,
|
||||||
|
}: StaggerListProps) {
|
||||||
|
const items = Children.toArray(children)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(className)}>
|
||||||
|
{items.map((child, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="stagger-item"
|
||||||
|
style={{ '--stagger-index': i, animationDelay: `${baseDelay + i * staggerMs}ms` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
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 { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield } from 'lucide-react'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
@@ -8,6 +8,7 @@ import { BrandLogo } from '@/components/common/BrandLogo'
|
|||||||
import { TopBar } from './TopBar'
|
import { TopBar } from './TopBar'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import { EmailVerificationBanner } from './EmailVerificationBanner'
|
import { EmailVerificationBanner } from './EmailVerificationBanner'
|
||||||
|
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
@@ -185,7 +186,7 @@ export function AppLayout() {
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="main-content overflow-y-auto">
|
<main className="main-content overflow-y-auto">
|
||||||
<EmailVerificationBanner />
|
<EmailVerificationBanner />
|
||||||
<Outlet />
|
<ViewTransitionOutlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
21
frontend/src/components/layout/ViewTransitionOutlet.tsx
Normal file
21
frontend/src/components/layout/ViewTransitionOutlet.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Outlet, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps <Outlet> 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 (
|
||||||
|
<div key={routeKey} className="animate-fade-in-up">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react'
|
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import { TagBadges } from '@/components/common/TagBadges'
|
import { TagBadges } from '@/components/common/TagBadges'
|
||||||
|
import { StaggerList } from '@/components/common/StaggerList'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { getTreeEditorPath } from '@/lib/routing'
|
import { getTreeEditorPath } from '@/lib/routing'
|
||||||
@@ -33,7 +34,7 @@ export function TreeGridView({
|
|||||||
const { canEditTree, canDeleteTree } = usePermissions()
|
const { canEditTree, canDeleteTree } = usePermissions()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<StaggerList className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<div
|
<div
|
||||||
key={tree.id}
|
key={tree.id}
|
||||||
@@ -182,6 +183,6 @@ export function TreeGridView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</StaggerList>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,11 @@
|
|||||||
80% { transform: rotate(-2deg); }
|
80% { transform: rotate(-2deg); }
|
||||||
100% { transform: rotate(0deg); }
|
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) ── */
|
/* ── Root CSS variables (non-theme: glass, shadows, layout) ── */
|
||||||
@@ -249,6 +254,12 @@
|
|||||||
animation: breatheGlow 3s ease-in-out infinite alternate;
|
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 {
|
@utility rdp-custom {
|
||||||
@apply text-foreground;
|
@apply text-foreground;
|
||||||
& .rdp-month { @apply w-full; }
|
& .rdp-month { @apply w-full; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react'
|
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
|
import { StaggerList } from '@/components/common/StaggerList'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
@@ -217,7 +218,7 @@ export function MyTreesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<StaggerList className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<div
|
<div
|
||||||
key={tree.id}
|
key={tree.id}
|
||||||
@@ -352,7 +353,7 @@ export function MyTreesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</StaggerList>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
|
import { StaggerList } from '@/components/common/StaggerList'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import type { Session, TreeListItem } from '@/types'
|
import type { Session, TreeListItem } from '@/types'
|
||||||
@@ -213,7 +214,8 @@ export function SessionHistoryPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
|
<StaggerList className="space-y-4">
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
@@ -301,16 +303,17 @@ export function SessionHistoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{hasMore ? (
|
</StaggerList>
|
||||||
<p className="text-center text-sm text-muted-foreground py-4">
|
{hasMore ? (
|
||||||
Showing the 50 most recent sessions
|
<p className="text-center text-sm text-muted-foreground py-4">
|
||||||
</p>
|
Showing the 50 most recent sessions
|
||||||
) : sessions.length > 0 ? (
|
</p>
|
||||||
<p className="text-center text-sm text-muted-foreground py-4">
|
) : sessions.length > 0 ? (
|
||||||
Showing all {sessions.length} sessions
|
<p className="text-center text-sm text-muted-foreground py-4">
|
||||||
</p>
|
Showing all {sessions.length} sessions
|
||||||
) : null}
|
</p>
|
||||||
</div>
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user