diff --git a/apps/web/package.json b/apps/web/package.json index 01f61df..4c529a9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", "clsx": "^2.1.1", + "framer-motion": "^12.38.0", "next": "^15.1.7", "next-auth": "^5.0.0-beta.25", "react": "^19.0.0", diff --git a/apps/web/src/app/(app)/admin/system-roles/page.tsx b/apps/web/src/app/(app)/admin/system-roles/page.tsx index ff21d62..913370f 100644 --- a/apps/web/src/app/(app)/admin/system-roles/page.tsx +++ b/apps/web/src/app/(app)/admin/system-roles/page.tsx @@ -4,11 +4,11 @@ const SystemRolesClient = dynamic( () => import("~/components/admin/SystemRolesClient.js").then((m) => m.SystemRolesClient), { loading: () => ( -
-
+
+
{[...Array(5)].map((_, i) => ( -
+
))}
diff --git a/apps/web/src/app/(app)/allocations/loading.tsx b/apps/web/src/app/(app)/allocations/loading.tsx index 320fd49..102fbb5 100644 --- a/apps/web/src/app/(app)/allocations/loading.tsx +++ b/apps/web/src/app/(app)/allocations/loading.tsx @@ -1,43 +1,43 @@ export default function AllocationsLoading() { return ( -
+
{/* Header */}
-
-
+
+
{/* Filter bar */}
-
-
-
+
+
+
{/* Table */}
{/* Header */}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
{/* Rows */} {[...Array(10)].map((_, i) => ( -
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
))}
diff --git a/apps/web/src/app/(app)/allocations/page.tsx b/apps/web/src/app/(app)/allocations/page.tsx index 3ff485d..d2cd713 100644 --- a/apps/web/src/app/(app)/allocations/page.tsx +++ b/apps/web/src/app/(app)/allocations/page.tsx @@ -4,12 +4,12 @@ const AllocationsClient = dynamic( () => import("~/components/allocations/AllocationsClient.js").then((m) => m.AllocationsClient), { loading: () => ( -
-
-
+
+
+
{[...Array(8)].map((_, i) => ( -
+
))}
diff --git a/apps/web/src/app/(app)/loading.tsx b/apps/web/src/app/(app)/loading.tsx index 1e9d8df..9e332c7 100644 --- a/apps/web/src/app/(app)/loading.tsx +++ b/apps/web/src/app/(app)/loading.tsx @@ -1,15 +1,15 @@ export default function AppLoading() { return ( -
-
-
+
+
+
{[0, 1, 2, 3].map((i) => ( -
+
))}
-
-
+
+
); } diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 20af9d0..772065b 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -530,7 +530,7 @@ export function ProjectsClient() {
{isLoading ? ( -
Loading projects…
+
Loading projects…
) : ( <>
@@ -558,7 +558,7 @@ export function ProjectsClient() { - {projects.map((project) => { + {projects.map((project, index) => { const isSelected = selection.selectedIds.has(project.id); return ( reorder(draggedId, project.id)} - className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }} > - {!isCollapsed && ( -
- {section.items.map((item) => ( - - {item.icon} - {item.label} - - ))} -
- )} + + {!isCollapsed && ( + +
+ {section.items.map((item) => { + const isActive = activeHrefSet.has(item.href); + return ( + + {isActive && ( + + )} + {item.icon} + {item.label} + + ); + })} +
+
+ )} +
); })} @@ -327,40 +349,68 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => - {!subCollapsed && ( -
- {entry.items.map((item) => ( - - {item.label} - - ))} -
- )} + + {!subCollapsed && ( + +
+ {entry.items.map((item) => { + const isActive = activeHrefSet.has(item.href); + return ( + + {isActive && ( + + )} + {item.label} + + ); + })} +
+
+ )} +
); } + const isActive = activeHrefSet.has(entry.href); return ( + {isActive && ( + + )} {entry.icon} - {entry.label} + {entry.label} ); })} @@ -430,7 +480,9 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
setChatOpen(true)} /> -
{children}
+
+ {children} +
{chatOpen && setChatOpen(false)} />}
{/* Floating chat FAB — always visible when panel is closed */} diff --git a/apps/web/src/components/layout/PageTransition.tsx b/apps/web/src/components/layout/PageTransition.tsx new file mode 100644 index 0000000..6d343f9 --- /dev/null +++ b/apps/web/src/components/layout/PageTransition.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { motion } from "framer-motion"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +interface PageTransitionProps { + children: ReactNode; + className?: string; +} + +export function PageTransition({ children, className }: PageTransitionProps) { + const pathname = usePathname(); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/notifications/BroadcastManagementClient.tsx b/apps/web/src/components/notifications/BroadcastManagementClient.tsx index 6218566..ac098a9 100644 --- a/apps/web/src/components/notifications/BroadcastManagementClient.tsx +++ b/apps/web/src/components/notifications/BroadcastManagementClient.tsx @@ -72,9 +72,9 @@ export function BroadcastManagementClient() { {isLoading && (
{[...Array(3)].map((_, i) => ( -
-
-
+
+
+
))}
diff --git a/apps/web/src/components/notifications/NotificationBell.tsx b/apps/web/src/components/notifications/NotificationBell.tsx index 4822db5..c223c78 100644 --- a/apps/web/src/components/notifications/NotificationBell.tsx +++ b/apps/web/src/components/notifications/NotificationBell.tsx @@ -5,6 +5,7 @@ import { createPortal } from "react-dom"; import { useSession } from "next-auth/react"; import Link from "next/link"; import type { Route } from "next"; +import { motion, useAnimationControls } from "framer-motion"; import { trpc } from "~/lib/trpc/client.js"; function relativeTime(date: Date): string { @@ -34,6 +35,9 @@ export function NotificationBell() { const { data: session, status } = useSession(); const isAuthenticated = status === "authenticated" && !!session?.user?.email; + const badgeControls = useAnimationControls(); + const prevUnreadRef = useRef(null); + const utils = trpc.useUtils(); const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, { @@ -48,6 +52,17 @@ export function NotificationBell() { retry: false, }); + // Bounce badge when unread count increases + useEffect(() => { + if (prevUnreadRef.current !== null && unreadCount > prevUnreadRef.current) { + void badgeControls.start({ + scale: [1, 1.3, 1], + transition: { duration: 0.3, ease: "easeInOut" }, + }); + } + prevUnreadRef.current = unreadCount; + }, [unreadCount, badgeControls]); + const openTaskCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0); const { data: notifications = [] } = trpc.notification.list.useQuery( @@ -160,9 +175,12 @@ export function NotificationBell() { {/* Unread notification badge (red) */} {unreadCount > 0 && ( - + {unreadCount > 99 ? "99+" : unreadCount} - + )} {/* Task count badge (orange) */} {openTaskCount > 0 && ( diff --git a/apps/web/src/components/notifications/NotificationCenterClient.tsx b/apps/web/src/components/notifications/NotificationCenterClient.tsx index ff087e6..b5f7431 100644 --- a/apps/web/src/components/notifications/NotificationCenterClient.tsx +++ b/apps/web/src/components/notifications/NotificationCenterClient.tsx @@ -190,9 +190,9 @@ export function NotificationCenterClient() { {isLoading && (
{[...Array(5)].map((_, i) => ( -
-
-
+
+
+
))}
diff --git a/apps/web/src/components/projects/BudgetStatusCard.tsx b/apps/web/src/components/projects/BudgetStatusCard.tsx index c92c327..90ed901 100644 --- a/apps/web/src/components/projects/BudgetStatusCard.tsx +++ b/apps/web/src/components/projects/BudgetStatusCard.tsx @@ -57,12 +57,12 @@ export function BudgetStatusCard({ projectId }: BudgetStatusCardProps) { if (isLoading) { return ( -
-
-
+
+
+
{[0, 1, 2, 3].map((i) => ( -
+
))}
diff --git a/apps/web/src/components/resources/ResourceDetail.tsx b/apps/web/src/components/resources/ResourceDetail.tsx index faed6c9..7c1d901 100644 --- a/apps/web/src/components/resources/ResourceDetail.tsx +++ b/apps/web/src/components/resources/ResourceDetail.tsx @@ -109,11 +109,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { if (loadingResource) { return (
-
-
-
+
+
+
- {[0, 1, 2, 3].map((i) =>
)} + {[0, 1, 2, 3].map((i) =>
)}
diff --git a/apps/web/src/components/ui/AnimatedModal.tsx b/apps/web/src/components/ui/AnimatedModal.tsx new file mode 100644 index 0000000..7eb4115 --- /dev/null +++ b/apps/web/src/components/ui/AnimatedModal.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { motion, AnimatePresence } from "framer-motion"; +import { useEffect, useCallback, useRef, type ReactNode } from "react"; + +interface AnimatedModalProps { + open: boolean; + onClose: () => void; + children: ReactNode; + className?: string; + overlayClassName?: string; + maxWidth?: string; +} + +export function AnimatedModal({ + open, + onClose, + children, + className, + overlayClassName, + maxWidth = "max-w-xl", +}: AnimatedModalProps) { + const panelRef = useRef(null); + + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }, + [onClose], + ); + + useEffect(() => { + if (!open) return; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [open, handleEscape]); + + return ( + + {open && ( +
+ {/* Overlay */} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/ui/AnimatedNumber.tsx b/apps/web/src/components/ui/AnimatedNumber.tsx new file mode 100644 index 0000000..e55703a --- /dev/null +++ b/apps/web/src/components/ui/AnimatedNumber.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { memo, useEffect, useRef, useState } from "react"; + +interface AnimatedNumberProps { + value: number; + duration?: number | undefined; + decimals?: number | undefined; + prefix?: string | undefined; + suffix?: string | undefined; + className?: string | undefined; +} + +function easeOutExpo(t: number): number { + return t === 1 ? 1 : 1 - Math.pow(2, -10 * t); +} + +function formatNumber(n: number, decimals: number): string { + return n.toLocaleString("de-DE", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +function AnimatedNumberInner({ + value, + duration = 800, + decimals = 0, + prefix = "", + suffix = "", + className, +}: AnimatedNumberProps) { + const [display, setDisplay] = useState(value); + const prevValueRef = useRef(value); + const rafRef = useRef(null); + + useEffect(() => { + const from = prevValueRef.current; + const to = value; + prevValueRef.current = to; + + if (from === to) { + setDisplay(to); + return; + } + + const startTime = performance.now(); + + function tick(now: number) { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = easeOutExpo(progress); + const current = from + (to - from) * eased; + + setDisplay(current); + + if (progress < 1) { + rafRef.current = requestAnimationFrame(tick); + } else { + setDisplay(to); + } + } + + rafRef.current = requestAnimationFrame(tick); + + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + }; + }, [value, duration]); + + return ( + + {prefix} + {formatNumber(display, decimals)} + {suffix} + + ); +} + +export const AnimatedNumber = memo(AnimatedNumberInner); diff --git a/apps/web/src/components/ui/DraggableTableRow.tsx b/apps/web/src/components/ui/DraggableTableRow.tsx index ef2d7b4..3da39dd 100644 --- a/apps/web/src/components/ui/DraggableTableRow.tsx +++ b/apps/web/src/components/ui/DraggableTableRow.tsx @@ -13,6 +13,7 @@ interface DraggableTableRowProps { onDrop: (draggedId: string) => void; children: React.ReactNode; className?: string; + style?: React.CSSProperties; } /** @@ -27,11 +28,13 @@ export function DraggableTableRow({ onDrop, children, className = "", + style, }: DraggableTableRowProps) { const [isDragOver, setIsDragOver] = useState(false); return ( { e.preventDefault(); diff --git a/apps/web/src/components/ui/FadeIn.tsx b/apps/web/src/components/ui/FadeIn.tsx new file mode 100644 index 0000000..34de6dc --- /dev/null +++ b/apps/web/src/components/ui/FadeIn.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { motion, type Variants } from "framer-motion"; +import { type ReactNode } from "react"; + +interface FadeInProps { + children: ReactNode; + delay?: number; + duration?: number; + direction?: "up" | "down" | "left" | "right" | "none"; + distance?: number; + className?: string; + once?: boolean; +} + +function getOffset( + direction: FadeInProps["direction"], + distance: number, +): { x: number; y: number } { + switch (direction) { + case "up": + return { x: 0, y: distance }; + case "down": + return { x: 0, y: -distance }; + case "left": + return { x: distance, y: 0 }; + case "right": + return { x: -distance, y: 0 }; + case "none": + default: + return { x: 0, y: 0 }; + } +} + +export function FadeIn({ + children, + delay = 0, + duration = 0.3, + direction = "up", + distance = 12, + className, + once = true, +}: FadeInProps) { + const offset = getOffset(direction, distance); + + const variants: Variants = { + hidden: { + opacity: 0, + x: offset.x, + y: offset.y, + }, + visible: { + opacity: 1, + x: 0, + y: 0, + transition: { + duration, + delay, + ease: [0.25, 0.1, 0.25, 1], + }, + }, + }; + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/ui/ProgressRing.tsx b/apps/web/src/components/ui/ProgressRing.tsx new file mode 100644 index 0000000..142c6c4 --- /dev/null +++ b/apps/web/src/components/ui/ProgressRing.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface ProgressRingProps { + value: number; + size?: number; + strokeWidth?: number; + color?: string; + trackColor?: string; + className?: string; + children?: React.ReactNode; + animated?: boolean; +} + +export function ProgressRing({ + value, + size = 40, + strokeWidth = 3, + color = "var(--color-blue-500, #3b82f6)", + trackColor = "var(--color-gray-200, #e5e7eb)", + className, + children, + animated = true, +}: ProgressRingProps) { + const [mounted, setMounted] = useState(!animated); + + useEffect(() => { + if (!animated) return; + // Trigger animation on next frame so the transition fires + const raf = requestAnimationFrame(() => setMounted(true)); + return () => cancelAnimationFrame(raf); + }, [animated]); + + const clamped = Math.max(0, Math.min(100, value)); + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (clamped / 100) * circumference; + const center = size / 2; + + return ( +
+ + {/* Track */} + + {/* Progress */} + + + {children != null && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/ui/ShimmerSkeleton.tsx b/apps/web/src/components/ui/ShimmerSkeleton.tsx new file mode 100644 index 0000000..1efeeed --- /dev/null +++ b/apps/web/src/components/ui/ShimmerSkeleton.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { Children, cloneElement, isValidElement, ReactNode } from "react"; + +/* ------------------------------------------------------------------ */ +/* ShimmerSkeleton */ +/* ------------------------------------------------------------------ */ + +interface ShimmerSkeletonProps { + className?: string; + width?: string | number; + height?: string | number; + rounded?: "sm" | "md" | "lg" | "xl" | "2xl" | "full"; + variant?: "text" | "circle" | "card" | "rect"; +} + +const roundedMap: Record = { + sm: "rounded-sm", + md: "rounded-md", + lg: "rounded-lg", + xl: "rounded-xl", + "2xl": "rounded-2xl", + full: "rounded-full", +}; + +const variantDefaults: Record< + string, + { width?: string | number; height?: string | number; rounded: string } +> = { + text: { width: "100%", height: "1em", rounded: "rounded" }, + circle: { width: 40, height: 40, rounded: "rounded-full" }, + card: { width: "100%", height: 120, rounded: "rounded-xl" }, + rect: { width: "100%", height: 40, rounded: "rounded-md" }, +}; + +export function ShimmerSkeleton({ + className = "", + width, + height, + rounded, + variant = "rect", +}: ShimmerSkeletonProps) { + const defaults = variantDefaults[variant] ?? variantDefaults.rect; + const resolvedWidth = width ?? defaults?.width ?? "100%"; + const resolvedHeight = height ?? defaults?.height ?? 40; + const resolvedRounded = rounded + ? roundedMap[rounded] ?? "rounded-md" + : defaults?.rounded ?? "rounded-md"; + + return ( +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* ShimmerGroup – staggers children entrance by 50ms each */ +/* ------------------------------------------------------------------ */ + +interface ShimmerGroupProps { + children: ReactNode; + staggerMs?: number; + className?: string; +} + +export function ShimmerGroup({ + children, + staggerMs = 50, + className, +}: ShimmerGroupProps) { + return ( +
+ {Children.map(children, (child, index) => { + if (!isValidElement(child)) return child; + return cloneElement(child as React.ReactElement<{ style?: React.CSSProperties }>, { + style: { + ...(child.props as { style?: React.CSSProperties }).style, + animationDelay: `${index * staggerMs}ms`, + }, + }); + })} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Inline