From 776113d73aef657c46d76b2ddbde36b4ab5ff6ad Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 2 Jun 2026 18:35:19 +0300 Subject: [PATCH 1/9] docs: add framer-motion animations plan --- plans/week4/framer-motion-animations.md | 204 ++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 plans/week4/framer-motion-animations.md diff --git a/plans/week4/framer-motion-animations.md b/plans/week4/framer-motion-animations.md new file mode 100644 index 0000000..609b6a8 --- /dev/null +++ b/plans/week4/framer-motion-animations.md @@ -0,0 +1,204 @@ +# AC 4 — Framer Motion Animations + +## Scope + +Add Framer Motion animations across the WorkTrace dashboard. +Five animation groups, ordered by visual impact. + +--- + +## 0. Installation + +```bash +cd dashboard && npm install framer-motion +``` + +--- + +## 1. Scroll-Reactive Background (Layout Layer) + +**File:** `dashboard/app/dashboard/layout.tsx` + +A fixed canvas sitting behind all dashboard content. Three gradient orbs at different z-depths move at different parallax speeds as the user scrolls. The orbs never move enough to be distracting — max 60–80px travel — but give the page a sense of depth. + +**Implementation:** + +Create `dashboard/components/parallax-bg.tsx` — a `"use client"` component: + +- `useScroll({ container: ref })` tracks scroll offset of the main scrollable div in the layout. +- Three `motion.div` orbs with `useTransform(scrollY, [0, 1000], [0, -N])` where N differs per orb (60 / 40 / 25 px), creating depth. +- Orbs: blurred radial gradients (cyan `#00ffff22`, purple `#8b5cf622`, pink `#ec489922`) — same palette as the app. +- `will-change: transform` + `pointer-events-none` — zero layout impact. + +Layout passes the scroll container ref into the component via context or direct prop. + +**Result:** Subtle 3D depth on every dashboard page, scroll down/up triggers smooth orb drift. + +--- + +## 2. Page Transitions (Between FEED / REPORTS / MUSIC) + +**File:** `dashboard/app/dashboard/layout.tsx` wrapping `{children}` + +Navigation order: FEED (0) → REPORTS (1) → MUSIC (2). + +**Implementation:** + +- Wrap `{children}` in ``. +- A `` client component wraps each page — it reads the current pathname to determine direction (left/right). +- Forward navigation (FEED→REPORTS, REPORTS→MUSIC): page slides in from right (`x: 40`), current page exits to left (`x: -40`). +- Backward navigation (MUSIC→REPORTS, REPORTS→FEED): reverse direction. +- Both combined with `opacity: 0 → 1` and a quick scale `0.97 → 1`. +- Duration: `0.3s ease-out` — fast enough not to feel slow, slow enough to register. + +``` +variants = { + enter(dir): { x: dir > 0 ? 40 : -40, opacity: 0, scale: 0.97 } + center: { x: 0, opacity: 1, scale: 1 } + exit(dir): { x: dir > 0 ? -40 : 40, opacity: 0, scale: 0.97 } +} +``` + +**Create:** `dashboard/components/page-transition.tsx` + +--- + +## 3. AI Report Loading Indicator + +**File:** `dashboard/app/dashboard/reports/report-form.tsx` → `LoadingPanel` + +Current: a single `animate-pulse` cyan bar + static text. + +Replace with a **three-layer animation**: + +**Layer A — Scanning bar:** A `motion.div` with a bright cyan gradient sweeps left→right infinitely (`x: "-100%" → "100%"`, `repeat: Infinity`, `duration: 1.4s`, `ease: "linear"`). Container is `overflow-hidden` with a subtle cyan track. + +**Layer B — Pulsing orbs:** Three small dots (`●●●`) each animate `opacity` and `y` with staggered delays (`0 / 0.15 / 0.3s`) — a breathing wave effect. + +**Layer C — Typewriter text:** "ASKING THE MODEL" text with each character using `staggerChildren` on mount. Characters animate `opacity: 0 → 1` and `y: 4 → 0`. + +Total visual: strong, clearly communicates "AI is working", fits the terminal/hacker aesthetic. + +--- + +## 4. Modal Animations (ApiKeyModal) + +**File:** `dashboard/app/dashboard/reports/api-key-modal.tsx` + +Current: instant appear/disappear via `createPortal`. + +**Implementation:** + +- Wrap the portal root in `` (conditional render → animate out before unmount). +- **Backdrop:** `motion.div` fades `opacity: 0 → 0.7`, `duration: 0.2s`. +- **Modal panel:** `motion.div` with spring physics: + ``` + initial: { opacity: 0, scale: 0.92, y: 16 } + animate: { opacity: 1, scale: 1, y: 0 } — spring(stiffness:400, damping:28) + exit: { opacity: 0, scale: 0.95, y: 8 } — duration: 0.15s + ``` +- Enter feels snappy (spring), exit is quick fade-shrink. +- Applies to all future modals (the pattern becomes reusable). + +--- + +## 5. Event Feed Cards (Staggered Entrance) + +**Files:** +- `dashboard/app/dashboard/event-feed.tsx` — list container +- `dashboard/app/dashboard/event-card.tsx` — individual card + +**Implementation:** + +Container (`motion.ul` / `motion.div`) uses `variants` with `staggerChildren: 0.05s`. + +Each card on mount: +``` +initial: { opacity: 0, y: 20 } +animate: { opacity: 1, y: 0 } — duration: 0.3s, ease: [0.25, 0.1, 0.25, 1] +``` + +- Only the first "page" of cards staggers (use `custom` prop to cap stagger at index 10 — beyond that, instant appear to avoid long delays on deep scroll). +- Hover: `whileHover={{ scale: 1.015, transition: { duration: 0.15 } }}` — subtle lift. +- The existing `border-purple` hover CSS stays; scale adds depth. + +--- + +## 6. Additional Targeted Animations + +| Location | Animation | +|---|---| +| `toast.tsx` | `AnimatePresence` + slide-in from right (`x: 120 → 0`), fade-out on dismiss | +| `mobile-nav.tsx` | Replace CSS `max-h` trick with `motion.div` height `0 → auto` via `layout` prop, spring easing | +| `header-nav.tsx` | Shared `layoutId="nav-indicator"` pill that slides between active links | +| `top-sessions.tsx` | Progress bars animate width `0 → actual%` on mount via `motion.div` | +| `music-client.tsx` | Summary stat numbers count up from 0 using custom `useCountUp` + `useInView` | +| `charts.tsx` | Fade-in container when data replaces skeleton (`AnimatePresence` swap) | +| `login/page.tsx` | Logo entrance: `scale: 0.8, opacity: 0 → 1`, staggered with button | + +--- + +## File Inventory + +### New files +``` +dashboard/components/parallax-bg.tsx — scroll-reactive background orbs +dashboard/components/page-transition.tsx — directional page slide wrapper +``` + +### Modified files +``` +dashboard/app/dashboard/layout.tsx — integrate ParallaxBg + PageTransition + AnimatePresence +dashboard/app/dashboard/event-feed.tsx — stagger container +dashboard/app/dashboard/event-card.tsx — card entrance + whileHover +dashboard/app/dashboard/reports/report-form.tsx — LoadingPanel overhaul +dashboard/app/dashboard/reports/api-key-modal.tsx — spring modal + AnimatePresence +dashboard/app/dashboard/header-nav.tsx — layoutId nav indicator +dashboard/app/dashboard/mobile-nav.tsx — motion height animation +dashboard/app/dashboard/top-sessions.tsx — progress bar animate +dashboard/app/dashboard/music/music-client.tsx — count-up stats +dashboard/components/toast.tsx — slide-in/out with AnimatePresence +dashboard/app/login/page.tsx — entrance stagger +``` + +--- + +## Technical Notes + +- **SSR safety:** All Framer Motion components that use `useScroll`/`useMotionValue` must be `"use client"`. Already the case for every file in scope. +- **`AnimatePresence mode="wait"`** on page transitions prevents overlap flash. +- **`layout` prop** on progress bars / nav indicator uses Framer's FLIP algorithm — no manual width math. +- **Reduced-motion:** Wrap all animation variants behind `useReducedMotion()` fallback — respect OS accessibility setting. +- **Bundle size:** `framer-motion` is ~45 kB gzipped. Acceptable for a dashboard app. Use tree-shaking (named imports only). + +--- + +## Commit Plan + +| # | Commit message | Що входить | +|---|---|---| +| 1 | `docs: add framer-motion animations plan` | цей файл `/plans/week4/framer-motion-animations.md` | +| 2 | `chore: install framer-motion` | `npm install framer-motion`, оновлений `package.json` / `package-lock.json` | +| 3 | `feat(anim): parallax background orbs on scroll` | `parallax-bg.tsx` + інтеграція в `dashboard/layout.tsx` | +| 4 | `feat(anim): directional page transitions` | `page-transition.tsx` + `AnimatePresence` у `dashboard/layout.tsx` | +| 5 | `feat(anim): AI report loading panel` | `reports/report-form.tsx` — `LoadingPanel` overhaul | +| 6 | `feat(anim): modal spring entrance + AnimatePresence` | `reports/api-key-modal.tsx` | +| 7 | `feat(anim): event feed stagger + card hover` | `event-feed.tsx`, `event-card.tsx` | +| 8 | `feat(anim): nav indicator, mobile-nav, progress bars` | `header-nav.tsx`, `mobile-nav.tsx`, `top-sessions.tsx` | +| 9 | `feat(anim): toasts slide, music count-up, login entrance` | `toast.tsx`, `music-client.tsx`, `login/page.tsx` | +| 10 | *(резервний)* `fix(anim): post-review fixes` | Залишається порожнім — для виправлень після твого огляду | + +--- + +## Acceptance Criteria (AC 4) + +- [ ] Framer Motion installed, no TS errors, existing tests green. +- [ ] Background orbs visible on all three dashboard pages; move on scroll. +- [ ] Page transition fires on FEED ↔ REPORTS ↔ MUSIC nav clicks (directional). +- [ ] AI report loading panel shows scanning bar + dot wave + typewriter text. +- [ ] ApiKeyModal scales in with spring; backdrop fades; both animate out on close. +- [ ] Event feed cards stagger in on first load; hover lifts cards. +- [ ] Toast slides in from right; slides out on dismiss. +- [ ] Nav active indicator slides between links (no jump). +- [ ] Top sessions progress bars animate from 0 on mount. +- [ ] `prefers-reduced-motion` respected — animations disabled when OS requests it. From 521c5b34ee3e3440b2c8fd63e183a3f28ad9d044 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 2 Jun 2026 18:35:41 +0300 Subject: [PATCH 2/9] chore: install framer-motion --- dashboard/package-lock.json | 26 +++++++++++++------------- dashboard/package.json | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 7907e85..a274674 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -13,7 +13,7 @@ "@tanstack/react-query": "^5.100.10", "chart.js": "^4.5.1", "d3": "^7.9.0", - "framer-motion": "^12.38.0", + "framer-motion": "^12.40.0", "google-auth-library": "^10.6.2", "jose": "^6.2.3", "next": "16.2.6", @@ -5769,13 +5769,13 @@ } }, "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "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.38.0", - "motion-utils": "^12.36.0", + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -8433,18 +8433,18 @@ } }, "node_modules/motion-dom": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", - "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "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.36.0" + "motion-utils": "^12.39.0" } }, "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "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": { diff --git a/dashboard/package.json b/dashboard/package.json index d9e9b64..b78170d 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -14,7 +14,7 @@ "@tanstack/react-query": "^5.100.10", "chart.js": "^4.5.1", "d3": "^7.9.0", - "framer-motion": "^12.38.0", + "framer-motion": "^12.40.0", "google-auth-library": "^10.6.2", "jose": "^6.2.3", "next": "16.2.6", From a7522c4e1b16102d3ce4823f6bed0d80c4407cf1 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 2 Jun 2026 19:52:00 +0300 Subject: [PATCH 3/9] feat(anim): scroll-reactive timers bg, prism wave, Lenis smooth scroll, page transitions --- dashboard/app/dashboard/event-feed.tsx | 7 + dashboard/app/dashboard/layout.tsx | 9 +- dashboard/app/providers.tsx | 9 +- dashboard/components/glitch-canvas.tsx | 80 +++++++ dashboard/components/page-transition.tsx | 44 ++++ dashboard/components/parallax-bg.tsx | 39 ++++ dashboard/components/smooth-scroll.tsx | 14 ++ dashboard/components/timer-field.tsx | 152 +++++++++++++ dashboard/package-lock.json | 275 +++++++++++++++++++++++ dashboard/package.json | 4 + 10 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 dashboard/components/glitch-canvas.tsx create mode 100644 dashboard/components/page-transition.tsx create mode 100644 dashboard/components/parallax-bg.tsx create mode 100644 dashboard/components/smooth-scroll.tsx create mode 100644 dashboard/components/timer-field.tsx diff --git a/dashboard/app/dashboard/event-feed.tsx b/dashboard/app/dashboard/event-feed.tsx index bc56865..b60bd5d 100644 --- a/dashboard/app/dashboard/event-feed.tsx +++ b/dashboard/app/dashboard/event-feed.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { useLenis } from "lenis/react"; import { EventCard } from "./event-card"; import { eventsQueryKey, @@ -39,6 +40,12 @@ export function EventFeed({ filters, onClearFilters }: Props) { ); const sentinelRef = useRef(null); + const lenis = useLenis(); + + // After each new batch loads, tell Lenis the page height changed + useEffect(() => { + lenis?.resize(); + }, [data?.pages.length, lenis]); useEffect(() => { const el = sentinelRef.current; diff --git a/dashboard/app/dashboard/layout.tsx b/dashboard/app/dashboard/layout.tsx index 9f9b61c..0416274 100644 --- a/dashboard/app/dashboard/layout.tsx +++ b/dashboard/app/dashboard/layout.tsx @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; import { getCurrentUser } from "@/server/current-user"; import { AppHeader } from "./app-header"; +import { ParallaxBg } from "@/components/parallax-bg"; +import { PageTransition } from "@/components/page-transition"; export default async function DashboardLayout({ children, @@ -9,9 +11,12 @@ export default async function DashboardLayout({ if (!user) redirect("/login"); return ( -
+
+ -
{children}
+
+ {children} +
); } diff --git a/dashboard/app/providers.tsx b/dashboard/app/providers.tsx index 9d965e3..b2893db 100644 --- a/dashboard/app/providers.tsx +++ b/dashboard/app/providers.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ToastProvider, useToast } from "@/components/toast"; +import { SmoothScroll } from "@/components/smooth-scroll"; function QueryProviderInner({ children }: { children: React.ReactNode }) { const toast = useToast(); @@ -33,8 +34,10 @@ function QueryProviderInner({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); } diff --git a/dashboard/components/glitch-canvas.tsx b/dashboard/components/glitch-canvas.tsx new file mode 100644 index 0000000..70a3f1f --- /dev/null +++ b/dashboard/components/glitch-canvas.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useRef } from "react"; +import { Canvas, useFrame } from "@react-three/fiber"; + +const vert = /* glsl */ ` + varying vec2 vUv; + void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } +`; + +const frag = /* glsl */ ` + uniform float uTime; + uniform float uWave; + varying vec2 vUv; + + vec3 sampleBg(vec2 uv) { + vec3 bg = vec3(0.004, 0.004, 0.005); + float d1 = length(uv - vec2(0.08, 0.88)); + float d2 = length(uv - vec2(0.92, 0.62)); + float d3 = length(uv - vec2(0.50, 0.02)); + vec3 c = bg + + mix(vec3(0.12), vec3(0.0, 0.898, 0.690), 0.28) * smoothstep(0.55, 0.0, d1) * 0.07 + + mix(vec3(0.10), vec3(0.486, 0.227, 1.0), 0.24) * smoothstep(0.50, 0.0, d2) * 0.06 + + mix(vec3(0.08), vec3(1.0, 0.0, 0.439), 0.20) * smoothstep(0.45, 0.0, d3) * 0.04; + float v = length((uv - 0.5) * 2.0); + return c * (1.0 - smoothstep(0.25, 1.0, v) * 0.78); + } + + void main() { + float w1 = sin(vUv.y * 9.0 + uTime * 1.6) * 0.022; + float w2 = sin(vUv.y * 5.5 - uTime * 1.1) * 0.014; + float w3 = sin(vUv.x * 7.0 + uTime * 2.0) * 0.011; + vec2 wd = vec2(w1 + w2, w3); + float d = uWave * 0.042; + vec3 r = sampleBg(vUv + wd * d); + vec3 g = sampleBg(vUv); + vec3 b = sampleBg(vUv - wd * d); + gl_FragColor = vec4(r.r, g.g, b.b, 1.0); + } +`; + +// Module-level: lives outside React, freely mutable by useFrame each tick. +// The ESLint react-hooks/immutability rule only guards hook-returned values. +const bgUniforms = { uTime: { value: 0 }, uWave: { value: 0 } }; + +function Background({ intensityRef }: { intensityRef: React.MutableRefObject }) { + const timeRef = useRef(0); + const waveRef = useRef(0); + + useFrame((_, delta) => { + const t = intensityRef.current; + waveRef.current += (t - waveRef.current) * (t > waveRef.current ? 0.07 : 0.045); + timeRef.current += delta; + bgUniforms.uTime.value = timeRef.current; + bgUniforms.uWave.value = waveRef.current; + }); + + return ( + + + + + ); +} + +type Props = { intensityRef: React.MutableRefObject }; + +export default function GlitchCanvas({ intensityRef }: Props) { + return ( + + + + ); +} diff --git a/dashboard/components/page-transition.tsx b/dashboard/components/page-transition.tsx new file mode 100644 index 0000000..a2cf53a --- /dev/null +++ b/dashboard/components/page-transition.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; + +const NAV_ORDER: Record = { + "/dashboard": 0, + "/dashboard/reports": 1, + "/dashboard/music": 2, +}; + +export function PageTransition({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const reduced = useReducedMotion(); + + // getDerivedStateFromProps pattern: call setState during render (not in an effect). + // React re-runs the render synchronously with updated state, so the entering + // motion.div always mounts with the correct direction in its `initial` prop. + const [nav, setNav] = useState({ prevPath: pathname, dir: 0 }); + + if (nav.prevPath !== pathname) { + const prev = NAV_ORDER[nav.prevPath] ?? 0; + const curr = NAV_ORDER[pathname] ?? 0; + setNav({ prevPath: pathname, dir: curr >= prev ? 1 : -1 }); + } + + const dir = nav.dir; + + return ( + + + {children} + + + ); +} diff --git a/dashboard/components/parallax-bg.tsx b/dashboard/components/parallax-bg.tsx new file mode 100644 index 0000000..930acb7 --- /dev/null +++ b/dashboard/components/parallax-bg.tsx @@ -0,0 +1,39 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useRef } from "react"; +import { useMotionValueEvent, useReducedMotion, useScroll, useVelocity } from "framer-motion"; +import { TimerField } from "./timer-field"; + +const GlitchCanvas = dynamic(() => import("./glitch-canvas"), { ssr: false }); + +const VELOCITY_THRESHOLD = 80; + +export function ParallaxBg() { + const { scrollY } = useScroll(); + const velocity = useVelocity(scrollY); + const reduced = useReducedMotion(); + const intensityRef = useRef(0); + + useMotionValueEvent(velocity, "change", (v) => { + if (reduced) return; + const speed = Math.abs(v); + intensityRef.current = speed > VELOCITY_THRESHOLD + ? Math.min((speed - VELOCITY_THRESHOLD) / 520, 1) + : 0; + }); + + if (reduced) return null; + + return ( + <> + {/* Three.js: dark base + prism wave on scroll */} +
+ +
+ + {/* 2D canvas: timer field — green idle, red + downward on scroll */} + + + ); +} diff --git a/dashboard/components/smooth-scroll.tsx b/dashboard/components/smooth-scroll.tsx new file mode 100644 index 0000000..e31a492 --- /dev/null +++ b/dashboard/components/smooth-scroll.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { ReactLenis } from "lenis/react"; +import type { LenisOptions } from "lenis"; + +const OPTIONS: LenisOptions = { + duration: 1.4, // inertia length in seconds + easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // expo ease-out + smoothWheel: true, +}; + +export function SmoothScroll({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/dashboard/components/timer-field.tsx b/dashboard/components/timer-field.tsx new file mode 100644 index 0000000..dffefa4 --- /dev/null +++ b/dashboard/components/timer-field.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useMotionValueEvent, useReducedMotion, useScroll, useVelocity } from "framer-motion"; + +// ─── Config ─────────────────────────────────────────────────────────────────── + +const COUNT = 85; +const BASE_FONT = 11; // px — sizes range 0.6× – 1.5× +const SCROLL_THRESHOLD = 80; // px/s before effects kick in +const REVERSE_SPEED = 45; // ms subtracted per real-ms when scrolling fast + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function fmt(ms: number): string { + const total = Math.max(0, Math.floor(Math.abs(ms))); + const h = Math.floor(total / 3_600_000); + const m = Math.floor((total % 3_600_000) / 60_000); + const s = Math.floor((total % 60_000) / 1_000); + const cs = Math.floor((total % 1_000) / 10); + return h > 0 + ? `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` + : `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(cs).padStart(2, "0")}`; +} + +interface T { + x: number; // px + y: number; // px (drifts with scroll) + size: number; // font scale 0.6–1.5 + ms: number; // current timer value in ms + driftX: number; // gentle horizontal drift + driftY: number; // gentle vertical drift (natural) +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function TimerField() { + const canvasRef = useRef(null); + const timersRef = useRef([]); + const velRef = useRef(0); + const rafRef = useRef(0); + const reduced = useReducedMotion(); + + const { scrollY } = useScroll(); + const velocity = useVelocity(scrollY); + + useMotionValueEvent(velocity, "change", (v) => { velRef.current = v; }); + + // Init timers after canvas mounts so we have window dimensions + useEffect(() => { + const W = window.innerWidth; + const H = window.innerHeight; + timersRef.current = Array.from({ length: COUNT }, () => ({ + x: Math.random() * W, + y: Math.random() * H, + size: 0.6 + Math.random() * 0.9, + ms: Math.random() * 5_400_000, // 0–90 min spread + driftX: (Math.random() - 0.5) * 0.05, + driftY: (Math.random() - 0.5) * 0.06, + })); + }, []); + + useEffect(() => { + if (reduced) return; + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d")!; + let last = performance.now(); + + const c = canvas; // stable reference for closures + + function resize() { + c.width = window.innerWidth; + c.height = window.innerHeight; + } + resize(); + window.addEventListener("resize", resize); + + function draw(now: number) { + const dt = Math.min(now - last, 50); + last = now; + + const speed = Math.abs(velRef.current); + const intensity = Math.max(0, Math.min((speed - SCROLL_THRESHOLD) / 520, 1)); + const scrolling = speed > SCROLL_THRESHOLD; + + ctx.clearRect(0, 0, c.width, c.height); + + for (const t of timersRef.current) { + // ── Time value ────────────────────────────────────────────────────── + if (scrolling) { + t.ms -= dt * (1 + intensity * REVERSE_SPEED); // reverse + accelerate + if (t.ms < 0) t.ms = 5_400_000; + } else { + t.ms += dt; // normal forward tick + if (t.ms > 5_400_000) t.ms = 0; + } + + // ── Position ──────────────────────────────────────────────────────── + t.x += t.driftX; + // natural drift + downward parallax proportional to depth (size) + t.y += t.driftY + intensity * t.size * 2.2; + + // wrap + if (t.x < -80) t.x = c.width + 60; + if (t.x > c.width + 80) t.x = -60; + if (t.y < -20) t.y = c.height + 10; + if (t.y > c.height + 20) t.y = -10; + + // ── Style ─────────────────────────────────────────────────────────── + const fontSize = Math.round(BASE_FONT * t.size); + + if (scrolling) { + // Aggressive red — brighter/stronger with intensity + const g = Math.round((1 - intensity) * 40); + const a = 0.45 + intensity * 0.50; + ctx.fillStyle = `rgba(255, ${g}, 30, ${a})`; + ctx.shadowColor = `rgba(255, 0, 70, ${intensity * 0.9})`; + ctx.shadowBlur = 4 + intensity * 12; + } else { + // Default green — dim, subtle + const a = 0.15 + t.size * 0.10; + ctx.fillStyle = `rgba(0, 229, 176, ${a})`; + ctx.shadowColor = "rgba(0, 229, 176, 0.25)"; + ctx.shadowBlur = 2; + } + + ctx.font = `${fontSize}px "Courier New", monospace`; + ctx.fillText(fmt(t.ms), t.x, t.y); + } + + ctx.shadowBlur = 0; + rafRef.current = requestAnimationFrame(draw); + } + + rafRef.current = requestAnimationFrame(draw); + return () => { + cancelAnimationFrame(rafRef.current); + window.removeEventListener("resize", resize); + }; + }, [reduced]); + + if (reduced) return null; + + return ( + + ); +} diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index a274674..99b417a 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -10,12 +10,15 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@react-three/fiber": "^9.6.1", "@tanstack/react-query": "^5.100.10", + "@types/three": "^0.184.1", "chart.js": "^4.5.1", "d3": "^7.9.0", "framer-motion": "^12.40.0", "google-auth-library": "^10.6.2", "jose": "^6.2.3", + "lenis": "^1.3.23", "next": "16.2.6", "pg": "^8.20.0", "prisma": "^7.8.0", @@ -24,6 +27,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "three": "^0.184.0", "zod": "^4.4.3" }, "devDependencies": { @@ -245,6 +249,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -293,6 +306,12 @@ "node": ">=6.9.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@electric-sql/pglite": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", @@ -1769,6 +1788,54 @@ } } }, + "node_modules/@react-three/fiber": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", + "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2095,6 +2162,12 @@ "react": "^18 || ^19" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2575,6 +2648,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -2596,12 +2678,38 @@ "@types/node": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -3703,6 +3811,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -5628,6 +5760,12 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6325,6 +6463,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6924,6 +7082,18 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -7108,6 +7278,37 @@ "node": ">=0.10" } }, + "node_modules/lenis": { + "version": "1.3.23", + "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz", + "integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==", + "license": "MIT", + "workspaces": [ + "packages/*", + "playground", + "playground/*" + ], + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "react": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7805,6 +8006,12 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -9612,6 +9819,21 @@ "react": ">=18" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -10540,6 +10762,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -10561,6 +10792,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -11067,6 +11304,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/valibot": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", @@ -11310,6 +11556,35 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index b78170d..1558371 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -11,12 +11,15 @@ "dependencies": { "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@react-three/fiber": "^9.6.1", "@tanstack/react-query": "^5.100.10", + "@types/three": "^0.184.1", "chart.js": "^4.5.1", "d3": "^7.9.0", "framer-motion": "^12.40.0", "google-auth-library": "^10.6.2", "jose": "^6.2.3", + "lenis": "^1.3.23", "next": "16.2.6", "pg": "^8.20.0", "prisma": "^7.8.0", @@ -25,6 +28,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "three": "^0.184.0", "zod": "^4.4.3" }, "devDependencies": { From 7511cbec17eeea69117660e63b1303f439288aad Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 2 Jun 2026 19:58:28 +0300 Subject: [PATCH 4/9] feat(anim): AI report loading panel + modal spring entrance --- .../app/dashboard/reports/api-key-modal.tsx | 182 ++++++++++-------- .../app/dashboard/reports/report-form.tsx | 38 +++- 2 files changed, 137 insertions(+), 83 deletions(-) diff --git a/dashboard/app/dashboard/reports/api-key-modal.tsx b/dashboard/app/dashboard/reports/api-key-modal.tsx index 6527fed..e50be5d 100644 --- a/dashboard/app/dashboard/reports/api-key-modal.tsx +++ b/dashboard/app/dashboard/reports/api-key-modal.tsx @@ -2,16 +2,18 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; +import { AnimatePresence, motion } from "framer-motion"; import type { UserSettingsView } from "@/server/user-settings"; import { saveApiKey } from "./reports-client"; type Props = { + open: boolean; status: UserSettingsView; onClose: () => void; onSaved: (next: UserSettingsView) => void; }; -export function ApiKeyModal({ status, onClose, onSaved }: Props) { +export function ApiKeyModal({ open, status, onClose, onSaved }: Props) { const [value, setValue] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -37,89 +39,113 @@ export function ApiKeyModal({ status, onClose, onSaved }: Props) { } return createPortal( -
{ if (e.target === e.currentTarget) onClose(); }} - > -
-
-

- GROQ API KEY -

- -
+
+
+

+ GROQ API KEY +

+ +
-

- {status.hasGroqApiKey - ? <>Currently using your own key (••••{status.last4 ?? "????"}). To change, enter a new key below. - : <>Currently using the shared default key. Paste your own Groq API key to override. - } -

+

+ {status.hasGroqApiKey + ? <>Currently using your own key (••••{status.last4 ?? "????"}). To change, enter a new key below. + : <>Currently using the shared default key. Paste your own Groq API key to override. + } +

- - setValue(e.target.value)} - placeholder="gsk_…" - disabled={busy} - autoComplete="off" - className="mb-2 w-full rounded border border-border bg-bg px-2 py-1.5 text-xs text-text outline-none transition-colors focus:border-purple disabled:opacity-50" - /> + + setValue(e.target.value)} + placeholder="gsk_…" + disabled={busy} + autoComplete="off" + className="mb-2 w-full rounded border border-border bg-bg px-2 py-1.5 text-xs text-text outline-none transition-colors focus:border-purple disabled:opacity-50" + /> - {error ? ( -

{error}

- ) : null} + {error ? ( +

{error}

+ ) : null} -

- Stored encrypted (AES-256-GCM) and never returned to the browser. -

+

+ Stored encrypted (AES-256-GCM) and never returned to the browser. +

-
- {status.hasGroqApiKey ? ( - - ) : null} - - -
-
-
, +
+ {status.hasGroqApiKey ? ( + + ) : null} + + +
+
+ + + )} + , document.body, ); } diff --git a/dashboard/app/dashboard/reports/report-form.tsx b/dashboard/app/dashboard/reports/report-form.tsx index 81036c9..b0419c6 100644 --- a/dashboard/app/dashboard/reports/report-form.tsx +++ b/dashboard/app/dashboard/reports/report-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; import type { GenerateReportResponse, RangePreset } from "@/types/report"; import type { UserSettingsView } from "@/server/user-settings"; import { @@ -189,8 +190,9 @@ export function ReportForm() { {status.kind === "error" ? : null} {status.kind === "success" ? : null} - {modalOpen && apiKey ? ( + {apiKey ? ( setModalOpen(false)} onSaved={(next) => setApiKey(next)} @@ -216,10 +218,36 @@ function CustomField({ label, children }: { label: string; children: React.React function LoadingPanel() { return ( -
-
-
- Asking the model… this typically takes 10–30 seconds. +
+ {/* Layer A — scanning bar */} +
+ +
+ +
+ {/* Layer B — dot wave */} +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ + {/* Layer C — text */} +
+ + ASKING THE MODEL + + typically 10–30 seconds +
); From a04f8c93922953053fcb73a77b314985818875e7 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 2 Jun 2026 19:58:44 +0300 Subject: [PATCH 5/9] feat(anim): event feed stagger entrance + card hover lift --- dashboard/app/dashboard/event-card.tsx | 8 +++++-- dashboard/app/dashboard/event-feed.tsx | 30 +++++++++++++++++++++----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/dashboard/app/dashboard/event-card.tsx b/dashboard/app/dashboard/event-card.tsx index 3002d16..d7908f2 100644 --- a/dashboard/app/dashboard/event-card.tsx +++ b/dashboard/app/dashboard/event-card.tsx @@ -2,6 +2,7 @@ import type { EventDTO } from "@/types/event"; import { useClientLocalized } from "./use-client-localized"; +import { motion } from "framer-motion"; function hostnameOf(url: string): string { try { @@ -30,7 +31,10 @@ export function EventCard({ event }: { event: EventDTO }) { const time = useClientLocalized(event.timestamp, utcShort, localShort); return ( - + ); } diff --git a/dashboard/app/dashboard/event-feed.tsx b/dashboard/app/dashboard/event-feed.tsx index b60bd5d..6d9e576 100644 --- a/dashboard/app/dashboard/event-feed.tsx +++ b/dashboard/app/dashboard/event-feed.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef } from "react"; import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; import { useLenis } from "lenis/react"; +import { motion } from "framer-motion"; import { EventCard } from "./event-card"; import { eventsQueryKey, @@ -67,15 +68,34 @@ export function EventFeed({ filters, onClearFilters }: Props) { if (isError) return refetch()} />; if (events.length === 0) return ; + const listVariants = { + visible: { transition: { staggerChildren: 0.05 } }, + }; + const itemVariants = { + hidden: { opacity: 0, y: 16 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] as const } }, + }; + return ( <> -
    - {events.map((event) => ( -
  • + + {events.map((event, idx) => ( + -
  • + ))} -
+ {hasNextPage ? (
Date: Tue, 2 Jun 2026 19:59:32 +0300 Subject: [PATCH 6/9] feat(anim): nav indicator layoutId, mobile-nav spring, progress bars animate --- dashboard/app/dashboard/header-nav.tsx | 14 ++++- dashboard/app/dashboard/mobile-nav.tsx | 78 ++++++++++++++---------- dashboard/app/dashboard/top-sessions.tsx | 7 ++- 3 files changed, 62 insertions(+), 37 deletions(-) diff --git a/dashboard/app/dashboard/header-nav.tsx b/dashboard/app/dashboard/header-nav.tsx index 0fb05a0..aea349c 100644 --- a/dashboard/app/dashboard/header-nav.tsx +++ b/dashboard/app/dashboard/header-nav.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { motion } from "framer-motion"; const ITEMS: { href: string; label: string }[] = [ { href: "/dashboard", label: "FEED" }, @@ -21,11 +22,18 @@ export function HeaderNav() { key={item.href} href={item.href} className={ - "rounded px-2.5 py-1 transition-colors " + - (active ? "bg-cyan/10 text-cyan" : "text-muted hover:text-text") + "relative rounded px-2.5 py-1 transition-colors " + + (active ? "text-cyan" : "text-muted hover:text-text") } > - {item.label} + {active && ( + + )} + {item.label} ); })} diff --git a/dashboard/app/dashboard/mobile-nav.tsx b/dashboard/app/dashboard/mobile-nav.tsx index 3630f38..78703ad 100644 --- a/dashboard/app/dashboard/mobile-nav.tsx +++ b/dashboard/app/dashboard/mobile-nav.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; const ITEMS: { href: string; label: string }[] = [ { href: "/dashboard", label: "FEED" }, @@ -16,7 +17,7 @@ export function MobileNav() { return ( <> - {/* Burger button — mobile only */} + {/* Burger button */} {/* Backdrop */} - {open && ( -
setOpen(false)} - /> - )} + + {open && ( + setOpen(false)} + /> + )} + {/* Slide-down panel */} -
- -
+ + {open && ( + + + + )} + ); } diff --git a/dashboard/app/dashboard/top-sessions.tsx b/dashboard/app/dashboard/top-sessions.tsx index bc1f30f..a323400 100644 --- a/dashboard/app/dashboard/top-sessions.tsx +++ b/dashboard/app/dashboard/top-sessions.tsx @@ -1,6 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { motion } from "framer-motion"; import type { TopSession } from "@/types/event"; import { fetchTopSessions, topSessionsQueryKey } from "./feed-shared"; import { useClientLocalized } from "./use-client-localized"; @@ -104,9 +105,11 @@ function SessionRow({ session }: { session: TopSession }) {
-
From c4a53af947330b4cac6bc3da2b7355408d9a73a8 Mon Sep 17 00:00:00 2001 From: Bohdan Date: Tue, 2 Jun 2026 19:59:50 +0300 Subject: [PATCH 7/9] feat(anim): toasts slide-in, music count-up stats, login stagger entrance --- .../app/dashboard/music/music-client.tsx | 53 +++++++++++++------ dashboard/app/login/login-entrance.tsx | 34 ++++++++++++ dashboard/app/login/page.tsx | 31 ++++++----- dashboard/components/toast.tsx | 51 ++++++++++-------- 4 files changed, 117 insertions(+), 52 deletions(-) create mode 100644 dashboard/app/login/login-entrance.tsx diff --git a/dashboard/app/dashboard/music/music-client.tsx b/dashboard/app/dashboard/music/music-client.tsx index d65c71f..73e2fed 100644 --- a/dashboard/app/dashboard/music/music-client.tsx +++ b/dashboard/app/dashboard/music/music-client.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { animate } from "framer-motion"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { MusicStats } from "@/types/music-stats"; import { TopArtistsChart } from "./top-artists-chart"; @@ -26,6 +27,22 @@ async function fetchMusicStats(range: Preset): Promise { return res.json() as Promise; } +function useCountUp(target: number): number { + const [display, setDisplay] = useState(0); + const prev = useRef(0); + useEffect(() => { + const from = prev.current; + prev.current = target; + const controls = animate(from, target, { + duration: 0.8, + ease: "easeOut", + onUpdate: (v) => setDisplay(Math.round(v)), + }); + return () => controls.stop(); + }, [target]); + return display; +} + function fmtListened(ms: number): string { const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); @@ -78,20 +95,12 @@ export function MusicClient() { {data ? ( <> - {/* Summary row */} -
- - LISTENED: {fmtListened(data.totalListenedMs)} - - · - - ARTISTS: {data.totalArtists} - - · - - TRACKS: {data.totalTracks} - -
+ {/* Summary row with count-up */} + {data.totalTracks === 0 ? (
@@ -143,6 +152,20 @@ export function MusicClient() { ); } +function SummaryRow({ listenedMs, artists, tracks }: { listenedMs: number; artists: number; tracks: number }) { + const animArtists = useCountUp(artists); + const animTracks = useCountUp(tracks); + return ( +
+ LISTENED: {fmtListened(listenedMs)} + · + ARTISTS: {animArtists} + · + TRACKS: {animTracks} +
+ ); +} + function SkeletonGrid() { return (
diff --git a/dashboard/app/login/login-entrance.tsx b/dashboard/app/login/login-entrance.tsx new file mode 100644 index 0000000..42d0cee --- /dev/null +++ b/dashboard/app/login/login-entrance.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { motion } from "framer-motion"; + +const container = { + hidden: {}, + show: { transition: { staggerChildren: 0.12 } }, +}; + +const item = { + hidden: { opacity: 0, y: 20, scale: 0.96 }, + show: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.4, ease: [0.25, 0.1, 0.25, 1] as const } }, +}; + +export function LoginEntrance({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function LoginItem({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dashboard/app/login/page.tsx b/dashboard/app/login/page.tsx index 771a88e..cb4173a 100644 --- a/dashboard/app/login/page.tsx +++ b/dashboard/app/login/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Script from "next/script"; import { GoogleLoginButton } from "./google-button"; +import { LoginEntrance, LoginItem } from "./login-entrance"; export const metadata: Metadata = { title: "Sign in", @@ -13,8 +14,8 @@ export default function LoginPage() { return (
-
-
+ +
Sign in with the same Google account you use in the extension.

-
+
- {clientId ? ( - <> -