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 { 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 */}
|
||||
<main className="main-content overflow-y-auto">
|
||||
<EmailVerificationBanner />
|
||||
<Outlet />
|
||||
<ViewTransitionOutlet />
|
||||
</main>
|
||||
</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 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 (
|
||||
<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) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
@@ -182,6 +183,6 @@ export function TreeGridView({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</StaggerList>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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() {
|
||||
</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) => (
|
||||
<div
|
||||
key={tree.id}
|
||||
@@ -352,7 +353,7 @@ export function MyTreesPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</StaggerList>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<>
|
||||
<StaggerList className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
@@ -301,16 +303,17 @@ export function SessionHistoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasMore ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing the 50 most recent sessions
|
||||
</p>
|
||||
) : sessions.length > 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing all {sessions.length} sessions
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</StaggerList>
|
||||
{hasMore ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing the 50 most recent sessions
|
||||
</p>
|
||||
) : sessions.length > 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground py-4">
|
||||
Showing all {sessions.length} sessions
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user