Skip to content
Merged
17 changes: 13 additions & 4 deletions dashboard/app/dashboard/charts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -57,9 +58,17 @@ export function Charts({ filters }: { filters: FeedFilters }) {
}

return (
<>
<EventsByDayChart byDay={data.byDay} />
<TopTagsChart top={data.topTags} />
</>
<AnimatePresence mode="wait">
<motion.div
key="charts"
className="contents"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.35 }}
>
<EventsByDayChart byDay={data.byDay} />
<TopTagsChart top={data.topTags} />
</motion.div>
</AnimatePresence>
);
}
8 changes: 6 additions & 2 deletions dashboard/app/dashboard/event-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -30,7 +31,10 @@ export function EventCard({ event }: { event: EventDTO }) {
const time = useClientLocalized(event.timestamp, utcShort, localShort);

return (
<article className="flex flex-col gap-2 rounded border border-border bg-surface p-3 transition-colors hover:border-purple/60">
<motion.article
className="flex flex-col gap-2 rounded border border-border bg-surface p-3 transition-colors hover:border-purple/60"
whileHover={{ scale: 1.012, transition: { duration: 0.15 } }}
>
<div className="flex items-baseline justify-between gap-3">
<a
href={event.url}
Expand Down Expand Up @@ -70,6 +74,6 @@ export function EventCard({ event }: { event: EventDTO }) {
))}
</div>
) : null}
</article>
</motion.article>
);
}
37 changes: 32 additions & 5 deletions dashboard/app/dashboard/event-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -39,6 +41,12 @@ export function EventFeed({ filters, onClearFilters }: Props) {
);

const sentinelRef = useRef<HTMLDivElement | null>(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;
Expand All @@ -60,15 +68,34 @@ export function EventFeed({ filters, onClearFilters }: Props) {
if (isError) return <ErrorState onRetry={() => refetch()} />;
if (events.length === 0) return <EmptyState onClear={onClearFilters} />;

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 (
<>
<ul className="flex flex-col gap-3" aria-busy={isFetching}>
{events.map((event) => (
<li key={event.id}>
<motion.ul
className="flex flex-col gap-3"
aria-busy={isFetching}
variants={listVariants}
initial="hidden"
animate="visible"
>
{events.map((event, idx) => (
<motion.li
key={event.id}
variants={idx < 10 ? itemVariants : undefined}
initial={idx < 10 ? "hidden" : false}
animate="visible"
>
<EventCard event={event} />
</li>
</motion.li>
))}
</ul>
</motion.ul>

{hasNextPage ? (
<div
Expand Down
14 changes: 11 additions & 3 deletions dashboard/app/dashboard/header-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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 && (
<motion.span
layoutId="nav-pill"
className="absolute inset-0 rounded bg-cyan/10"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
<span className="relative z-10">{item.label}</span>
</Link>
);
})}
Expand Down
9 changes: 7 additions & 2 deletions dashboard/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,9 +11,12 @@ export default async function DashboardLayout({
if (!user) redirect("/login");

return (
<div className="flex min-h-full flex-1 flex-col bg-bg text-text">
<div className="relative flex min-h-full flex-1 flex-col text-text">
<ParallaxBg />
<AppHeader email={user.email} name={user.name} picture={user.picture} />
<main className="flex-1">{children}</main>
<main className="relative z-1 flex-1">
<PageTransition>{children}</PageTransition>
</main>
</div>
);
}
78 changes: 46 additions & 32 deletions dashboard/app/dashboard/mobile-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -16,7 +17,7 @@ export function MobileNav() {

return (
<>
{/* Burger button — mobile only */}
{/* Burger button */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
Expand All @@ -39,39 +40,52 @@ export function MobileNav() {
</button>

{/* Backdrop */}
{open && (
<div
className="fixed inset-0 z-40 sm:hidden"
onClick={() => setOpen(false)}
/>
)}
<AnimatePresence>
{open && (
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-40 sm:hidden"
onClick={() => setOpen(false)}
/>
)}
</AnimatePresence>

{/* Slide-down panel */}
<div
className={
"fixed inset-x-0 top-13 z-50 overflow-hidden border-b border-border bg-bg/95 backdrop-blur transition-all duration-200 sm:hidden " +
(open ? "max-h-40 opacity-100" : "max-h-0 opacity-0")
}
>
<nav className="mx-auto flex max-w-6xl flex-col px-6">
{ITEMS.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setOpen(false)}
className={
"border-b border-border/40 py-3.5 text-[10px] font-bold tracking-widest transition-colors last:border-0 " +
(active ? "text-cyan" : "text-muted hover:text-text")
}
>
{item.label}
</Link>
);
})}
</nav>
</div>
<AnimatePresence>
{open && (
<motion.div
key="panel"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.22, ease: [0.25, 0.1, 0.25, 1] }}
className="fixed inset-x-0 top-13 z-50 overflow-hidden border-b border-border bg-bg/95 backdrop-blur sm:hidden"
>
<nav className="mx-auto flex max-w-6xl flex-col px-6">
{ITEMS.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setOpen(false)}
className={
"border-b border-border/40 py-3.5 text-[10px] font-bold tracking-widest transition-colors last:border-0 " +
(active ? "text-cyan" : "text-muted hover:text-text")
}
>
{item.label}
</Link>
);
})}
</nav>
</motion.div>
)}
</AnimatePresence>
</>
);
}
57 changes: 42 additions & 15 deletions dashboard/app/dashboard/music/music-client.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -26,6 +27,22 @@ async function fetchMusicStats(range: Preset): Promise<MusicStats> {
return res.json() as Promise<MusicStats>;
}

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);
Expand Down Expand Up @@ -78,20 +95,12 @@ export function MusicClient() {

{data ? (
<>
{/* Summary row */}
<div className="flex flex-wrap gap-4 text-[10px] tracking-widest text-muted">
<span>
LISTENED: <span className="text-cyan">{fmtListened(data.totalListenedMs)}</span>
</span>
<span>·</span>
<span>
ARTISTS: <span className="text-cyan">{data.totalArtists}</span>
</span>
<span>·</span>
<span>
TRACKS: <span className="text-cyan">{data.totalTracks}</span>
</span>
</div>
{/* Summary row with count-up */}
<SummaryRow
listenedMs={data.totalListenedMs}
artists={data.totalArtists}
tracks={data.totalTracks}
/>

{data.totalTracks === 0 ? (
<div className="rounded border border-border bg-surface px-4 py-10 text-center text-[10px] tracking-widest text-muted">
Expand Down Expand Up @@ -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 (
<div className="flex flex-wrap gap-2 text-[10px] tracking-widest">
<span className="rounded border border-border bg-surface px-2.5 py-1 text-muted">
LISTENED <span className="ml-1 text-cyan">{fmtListened(listenedMs)}</span>
</span>
<span className="rounded border border-border bg-surface px-2.5 py-1 text-muted">
ARTISTS <span className="ml-1 text-cyan">{animArtists}</span>
</span>
<span className="rounded border border-border bg-surface px-2.5 py-1 text-muted">
TRACKS <span className="ml-1 text-cyan">{animTracks}</span>
</span>
</div>
);
}

function SkeletonGrid() {
return (
<div className="flex flex-col gap-4">
Expand Down
Loading
Loading