feat: framer-motion page transitions and UI polish

This commit is contained in:
null 2026-06-07 15:14:09 -05:00
parent 72d95065d0
commit ec7869abbc
20 changed files with 156 additions and 47 deletions

View File

@ -4,6 +4,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import Layout from '@/components/layout/Layout';
import AppNavigation from '@/components/layout/Sidebar';
import PageTransition from '@/components/PageTransition';
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
import CommandPalette from '@/components/CommandPalette';
import LoginPage from '@/pages/LoginPage';
@ -84,11 +85,15 @@ function RequireAuth({ children, role }) {
}
function AdminShell({ children }) {
const location = useLocation();
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground">
<AppNavigation adminMode />
<main className="mx-auto w-full max-w-[1500px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
{children}
<PageTransition routeKey={location.pathname}>
{children}
</PageTransition>
</main>
</div>
);

View File

@ -1101,8 +1101,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
Loading payment history...
</div>
) : payments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
No payments recorded for this bill.
<div className="flex flex-col items-center gap-1.5 rounded-lg bg-muted/20 px-3 py-8 text-center">
<p className="text-sm font-medium text-muted-foreground">No payments yet</p>
<p className="text-xs text-muted-foreground/60">Use the form below to record the first payment.</p>
</div>
) : (
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">

View File

@ -0,0 +1,21 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
export default function PageTransition({ children, routeKey }) {
const reduceMotion = useReducedMotion();
if (reduceMotion) return children;
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={routeKey}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
</AnimatePresence>
);
}

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { Link, Outlet, useLocation } from 'react-router-dom';
import AppNavigation from './Sidebar';
import { api } from '@/api';
import PageTransition from '@/components/PageTransition';
function SimplefinBadge() {
const [enabled, setEnabled] = useState(false);
@ -23,6 +24,8 @@ function SimplefinBadge() {
}
export default function Layout({ mainContentId }) {
const location = useLocation();
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_30rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.12))] text-foreground"
role="main"
@ -34,7 +37,9 @@ export default function Layout({ mainContentId }) {
<div className="mx-auto w-full max-w-[1500px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8"
id={mainContentId}
>
<Outlet />
<PageTransition routeKey={location.pathname}>
<Outlet />
</PageTransition>
</div>
</main>
<footer className="mx-auto flex w-full max-w-[1500px] flex-wrap items-center justify-center gap-x-4 gap-y-2 px-4 pb-6 text-xs text-muted-foreground sm:px-6 lg:px-8"

View File

@ -1,4 +1,5 @@
import { useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
@ -144,7 +145,9 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
return (
<>
<div
<motion.div
layout="position"
transition={{ layout: { duration: 0.2, ease: [0.22, 1, 0.36, 1] } }}
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
@ -359,7 +362,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
</div>
</div>
</motion.div>
{editPayment && (
<PaymentModal

View File

@ -103,9 +103,10 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
))}
</div>
) : (
<p className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
No payments recorded for this month.
</p>
<div className="flex flex-col items-center gap-1 rounded-md bg-muted/20 px-3 py-7 text-center">
<p className="text-sm font-medium text-muted-foreground">No payments yet</p>
<p className="text-xs text-muted-foreground/60">Add one below.</p>
</div>
)}
</div>

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import { LayoutGroup } from 'framer-motion';
import { ArrowDown, ArrowUp } from 'lucide-react';
import { cn, fmt } from '@/lib/utils';
import {
@ -160,6 +161,7 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
</div>
</div>
<LayoutGroup id={`tracker-bucket-mobile-${label}`}>
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
@ -186,8 +188,8 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
</div>
))
) : rows.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 bg-background/40 px-4 py-8 text-center text-sm text-muted-foreground">
No bills match this bucket and filter set.
<div className="rounded-lg bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
No bills match try adjusting your filters.
</div>
) : (
rows.map((r, i) => (
@ -206,6 +208,7 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
))
)}
</div>
</LayoutGroup>
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
<div className="overflow-x-auto">
@ -255,24 +258,26 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
) : rows.length === 0 ? (
<TableRow className="border-border/50">
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
No bills match this bucket and filter set.
No bills match try adjusting your filters.
</TableCell>
</TableRow>
) : (
rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
moveControls={moveControlsFor(r, i)}
dragProps={dragPropsFor(r, i)}
isDrifted={driftedIds.has(r.id)}
/>
))
<LayoutGroup id={`tracker-bucket-table-${label}`}>
{rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
moveControls={moveControlsFor(r, i)}
dragProps={dragPropsFor(r, i)}
isDrifted={driftedIds.has(r.id)}
/>
))}
</LayoutGroup>
)}
</TableBody>
</Table>

View File

@ -281,6 +281,8 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
return (
<>
<TableRow
layout="position"
transition={{ layout: { duration: 0.2, ease: [0.22, 1, 0.36, 1] } }}
data-tracker-row
tabIndex={0}
aria-rowindex={index + 1}

View File

@ -1,4 +1,5 @@
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
@ -20,19 +21,29 @@ function AlertDialogOverlay({ className, ...props }) {
);
}
function AlertDialogContent({ className, ...props }) {
function AlertDialogContent({ className, children, ...props }) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
asChild
role="dialog"
aria-modal="true"
{...props}
>
<motion.div
initial={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
animate={{ opacity: 1, x: '-50%', y: '-50%', scale: 1 }}
exit={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-md max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-md max-h-[calc(100svh-1rem)] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6',
className
)}
{...props}
/>
>
{children}
</motion.div>
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
);
}

View File

@ -1,4 +1,5 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { motion } from 'framer-motion';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
@ -25,20 +26,28 @@ function DialogContent({ className, children, ref, ...props }) {
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
asChild
ref={ref}
role="dialog"
aria-modal="true"
{...props}
>
<motion.div
initial={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
animate={{ opacity: 1, x: '-50%', y: '-50%', scale: 1 }}
exit={{ opacity: 0, x: '-50%', y: '-48%', scale: 0.98 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-lg max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-lg max-h-[calc(100svh-1rem)] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full p-1 opacity-70 transition-all hover:bg-accent hover:opacity-100 focus:outline-none focus:ring-[3px] focus:ring-ring/50 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground" aria-label="Close dialog">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</motion.div>
</DialogPrimitive.Content>
</DialogPortal>
);

View File

@ -1,3 +1,4 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
function Table({ className, ref, ...props }) {
@ -58,7 +59,7 @@ function TableFooter({ className, ref, ...props }) {
function TableRow({ className, ref, ...props }) {
return (
<tr
<motion.tr
ref={ref}
className={cn(
'border-b border-border/50 last:border-0',

View File

@ -501,7 +501,7 @@ function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
</div>
{ranges.length === 0 ? (
<div className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
<div className="rounded-lg bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground">
No ranges yet. Add a range to use selected history.
</div>
) : (

View File

@ -649,8 +649,8 @@ function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bills Due</h3>
{day.bills_due.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
No bills are due on this day.
<div className="rounded-lg bg-muted/15 p-4 text-sm text-muted-foreground">
No bills due this day.
</div>
) : (
<div className="space-y-2">

View File

@ -151,10 +151,10 @@ function ExpandedBills({ category }) {
if (!bills.length) {
return (
<div className="border-t border-border/60 bg-muted/15 px-4 py-5 sm:px-6">
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-border/70 bg-background/65 p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 rounded-lg bg-muted/20 p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>No bills in this category yet.</span>
<Button asChild variant="outline" size="sm" className="w-fit">
<Link to="/bills">Open Bills</Link>
<Link to="/bills">Add a bill </Link>
</Button>
</div>
</div>

View File

@ -111,7 +111,7 @@ function InputRow({ label, hint, children }) {
function EmptyDebts() {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 px-6 py-16 text-center">
<TrendingDown className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="text-sm font-medium text-foreground">No bills with a balance found</p>
<p className="text-xs text-muted-foreground mt-1">
@ -125,7 +125,7 @@ function EmptyDebts() {
function NoSelection() {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 px-6 py-16 text-center">
<Calculator className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="text-sm font-medium text-foreground">Select a loan or debt to begin</p>
<p className="text-xs text-muted-foreground mt-1">
@ -597,7 +597,7 @@ export default function PayoffPage() {
startBalance={activeBalance}
/>
) : (
<div className="flex items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 h-[300px] text-sm text-muted-foreground">
<div className="flex items-center justify-center rounded-xl bg-muted/20 h-[300px] text-sm text-muted-foreground">
{customNeedsBalance
? 'Enter a balance and payment to see the chart'
: simPaymentN <= 0

View File

@ -441,7 +441,7 @@ export default function RoadmapPage() {
<p className="text-sm text-muted-foreground">{roadmapError}</p>
</div>
) : issues.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 py-12 text-center text-sm text-muted-foreground">
<div className="rounded-xl bg-muted/20 py-12 text-center text-sm text-muted-foreground">
No open issues.
</div>
) : (
@ -475,7 +475,7 @@ export default function RoadmapPage() {
<p className="text-sm text-muted-foreground mt-1">{devLogError}</p>
</div>
) : devLogData && devLogData.entries?.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 py-12 text-center text-sm text-muted-foreground">
<div className="rounded-xl bg-muted/20 py-12 text-center text-sm text-muted-foreground">
No activity log entries found.
</div>
) : devLogData ? (

View File

@ -759,7 +759,7 @@ export default function SnowballPage() {
{/* Empty state */}
{bills.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 py-20 text-center gap-3">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/20 py-20 text-center gap-3">
<TrendingDown className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
<p className="text-xs text-muted-foreground/70 max-w-sm">

View File

@ -663,8 +663,9 @@ export default function SummaryPage() {
</div>
{expenses.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-6 text-sm text-muted-foreground">
No bills found for this month.
<div className="flex flex-col items-center gap-2 rounded-xl bg-muted/20 px-6 py-10 text-center">
<p className="text-sm font-medium text-muted-foreground">No bills for this month</p>
<a href="/bills" className="text-xs text-primary underline underline-offset-4 hover:opacity-80 transition-opacity">Add a bill </a>
</div>
) : (
<div className="rounded-2xl border border-border/60 bg-background/70 px-3">

43
package-lock.json generated
View File

@ -34,6 +34,7 @@
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^8.4.1",
"framer-motion": "^12.40.0",
"lucide-react": "^0.456.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.9",
@ -7007,6 +7008,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.40.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -9252,6 +9280,21 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/motion-dom": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@ -39,6 +39,7 @@
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^8.4.1",
"framer-motion": "^12.40.0",
"lucide-react": "^0.456.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.9",