diff --git a/dashboard/app/dashboard/charts.tsx b/dashboard/app/dashboard/charts.tsx index ff91231..8f26b6e 100644 --- a/dashboard/app/dashboard/charts.tsx +++ b/dashboard/app/dashboard/charts.tsx @@ -19,6 +19,7 @@ import { } from "./feed-shared"; import { EventsByDayChart } from "./events-by-day-chart"; import { TopTagsChart } from "./top-tags-chart"; +import { AnimatePresence, motion } from "framer-motion"; ChartJS.register( CategoryScale, @@ -57,9 +58,17 @@ export function Charts({ filters }: { filters: FeedFilters }) { } return ( - <> - - - + + + + + + ); } 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 bc56865..6d9e576 100644 --- a/dashboard/app/dashboard/event-feed.tsx +++ b/dashboard/app/dashboard/event-feed.tsx @@ -2,6 +2,8 @@ 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, @@ -39,6 +41,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; @@ -60,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 ? (
- {item.label} + {active && ( + + )} + {item.label} ); })} 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/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/music/music-client.tsx b/dashboard/app/dashboard/music/music-client.tsx index d65c71f..292bcc7 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,24 @@ 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/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..945f269 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 +
); @@ -243,14 +271,19 @@ function ErrorPanel({ message, onRetry }: { message: string; onRetry: () => void function ResultPanel({ result }: { result: GenerateReportResponse }) { return (
-
- RANGE: {result.range.label.toUpperCase()} - · - DETAIL LEVEL: {result.contextLevel} - · - ~{result.estimatedInputTokens.toLocaleString()} INPUT TOKENS - · - MODEL: {result.model} +
+ + RANGE {result.range.label.toUpperCase()} + + + DETAIL {result.contextLevel} + + + ~{result.estimatedInputTokens.toLocaleString()} TOKENS + + + MODEL {result.model} +
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 }) {
-
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 ? ( - <> -