This commit is contained in:
gosangam
2026-05-31 06:50:05 +05:30
parent 0ae3334d98
commit 0ff8005df2
9 changed files with 253 additions and 102 deletions
+11 -17
View File
@@ -8,7 +8,6 @@ import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings";
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
@@ -389,22 +388,17 @@ export function GlossaryClient({
<> <>
{/* Sticky header */} {/* Sticky header */}
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20"> <header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4"> <div className="max-w-5xl mx-auto">
<div className="flex-1 min-w-0"> <Link
<Link href="/"
href="/" className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" >
> &larr; Home
&larr; Home </Link>
</Link> <h1 className="mt-3 text-2xl font-bold text-foreground">
<h1 className="mt-3 text-2xl font-bold text-foreground"> <span className="text-primary">Glossary: </span>
<span className="text-primary">Glossary: </span> {activeEntry?.term || activeLetter}
{activeEntry?.term || activeLetter} </h1>
</h1>
</div>
<div className="shrink-0 pt-1">
<ReadingSettings />
</div>
</div> </div>
</header> </header>
-1
View File
@@ -82,7 +82,6 @@ export default function RootLayout({
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0"> <div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
<SiteHeader /> <SiteHeader />
{children} {children}
<SiteFooter />
</div> </div>
<SiteBottomNav /> <SiteBottomNav />
<TextSelectionToolbar /> <TextSelectionToolbar />
+18 -24
View File
@@ -4,7 +4,6 @@ import { useState, useMemo, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu } from "lucide-react"; import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
import { import {
@@ -451,29 +450,24 @@ export function FilterYearBrowser({
<> <>
{/* Header */} {/* Header */}
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20"> <header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4"> <div className="max-w-5xl mx-auto">
<div className="flex-1 min-w-0"> <Link
<Link href={backHref}
href={backHref} className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" >
> &larr; {backLabel}
&larr; {backLabel} </Link>
</Link> <h1 className="mt-3 text-2xl font-bold text-foreground">
<h1 className="mt-3 text-2xl font-bold text-foreground"> <span className="text-primary">
<span className="text-primary"> {titlePrefix} {filterName}:{" "}
{titlePrefix} {filterName}:{" "} </span>
</span> {activeYearLabel}
{activeYearLabel} </h1>
</h1> <p className="mt-1 text-muted-foreground">
<p className="mt-1 text-muted-foreground"> {activeGroup?.count ?? 0}{" "}
{activeGroup?.count ?? 0}{" "} {(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} in{" "}
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} in{" "} {activeYearLabel} · {totalCount} total
{activeYearLabel} · {totalCount} total </p>
</p>
</div>
<div className="shrink-0 pt-1">
<ReadingSettings />
</div>
</div> </div>
</header> </header>
+16 -22
View File
@@ -4,7 +4,6 @@ import { useState, useMemo, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu } from "lucide-react"; import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
import { import {
@@ -356,27 +355,22 @@ export function YearMonthBrowser({
<> <>
{/* Header */} {/* Header */}
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20"> <header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4"> <div className="max-w-5xl mx-auto">
<div className="flex-1 min-w-0"> <Link
<Link href="/letters?view=year"
href="/letters?view=year" className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" >
> &larr; Letters by Year
&larr; Letters by Year </Link>
</Link> <h1 className="mt-3 text-2xl font-bold text-foreground">
<h1 className="mt-3 text-2xl font-bold text-foreground"> <span className="text-primary">Letters: </span>
<span className="text-primary">Letters: </span> {activeLabel} {year}
{activeLabel} {year} </h1>
</h1> <p className="mt-1 text-muted-foreground">
<p className="mt-1 text-muted-foreground"> {activeGroup?.count ?? 0}{" "}
{activeGroup?.count ?? 0}{" "} {(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} ·{" "}
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} ·{" "} {totalCount} total in {year}
{totalCount} total in {year} </p>
</p>
</div>
<div className="shrink-0 pt-1">
<ReadingSettings />
</div>
</div> </div>
</header> </header>
+2 -2
View File
@@ -44,8 +44,8 @@ export default async function TranscriptionsPage({
Transcriptions Transcriptions
</h1> </h1>
<p className="mt-1 text-muted-foreground"> <p className="mt-1 text-muted-foreground">
{totalCount.toLocaleString()} lectures &amp; conversations of Śrīla {totalCount.toLocaleString()}&nbsp;lectures &amp; conversations of
Prabhupāda (1966–1977) Śrīla Prabhupāda (1966–1977)
</p> </p>
</PageHeader> </PageHeader>
+18 -24
View File
@@ -5,7 +5,6 @@ import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { ReadingSettings } from "@/components/reading-settings";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react"; import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button"; import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
@@ -477,29 +476,24 @@ export function TranscriptionTypeBrowser({
<> <>
{/* Header */} {/* Header */}
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20"> <header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4"> <div className="max-w-5xl mx-auto">
<div className="flex-1 min-w-0"> <Link
<Link href="/transcriptions?view=type"
href="/transcriptions?view=type" className="text-sm text-muted-foreground hover:text-foreground transition-colors"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" >
> &larr; Transcriptions by Type
&larr; Transcriptions by Type </Link>
</Link> <h1 className="mt-3 text-2xl font-bold text-foreground">
<h1 className="mt-3 text-2xl font-bold text-foreground"> <span className="text-primary">{typeLabel}: </span>
<span className="text-primary">{typeLabel}: </span> {activeYearLabel}
{activeYearLabel} </h1>
</h1> <p className="mt-1 text-muted-foreground">
<p className="mt-1 text-muted-foreground"> {activeGroup?.count ?? 0}{" "}
{activeGroup?.count ?? 0}{" "} {(activeGroup?.count ?? 0) === 1
{(activeGroup?.count ?? 0) === 1 ? "transcription"
? "transcription" : "transcriptions"}{" "}
: "transcriptions"}{" "} in {activeYearLabel} · {totalCount} total
in {activeYearLabel} · {totalCount} total </p>
</p>
</div>
<div className="shrink-0 pt-1">
<ReadingSettings />
</div>
</div> </div>
</header> </header>
+153 -9
View File
@@ -1,12 +1,12 @@
"use client"; "use client";
import { useRef, useCallback, useEffect, useState } from "react"; import { useRef, useCallback, useEffect, useMemo, useState } from "react";
import { useAudioPlayer } from "./audio-context"; import { useAudioPlayer } from "./audio-context";
import { AudioWaveAnimation } from "./audio-wave"; import { AudioWaveAnimation } from "./audio-wave";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence, useMotionValue } from "framer-motion";
import { import {
Play, Play,
Pause, Pause,
@@ -84,6 +84,59 @@ function useAutoMinimize(hasTrack: boolean) {
const FALLBACK_COVER = "/images/cover.png"; const FALLBACK_COVER = "/images/cover.png";
// Minimized pill (size-14 = 3.5rem = 56px) drag + persistence.
const MINI_PILL_SIZE = 56;
const MINI_EDGE_MARGIN = 16; // matches the old `right-4 bottom-6` resting state
const MINI_POS_STORAGE_KEY = "spsays:audio-mini-pos";
// Past this drag distance (px), treat the gesture as "drag" and suppress
// the click-to-expand that would otherwise fire on pointer-up.
const MINI_DRAG_THRESHOLD = 4;
type MiniPos = { x: number; y: number };
function clampMiniPos(p: MiniPos): MiniPos {
if (typeof window === "undefined") return p;
const maxX = window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN;
const maxY = window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN;
return {
x: Math.max(MINI_EDGE_MARGIN, Math.min(maxX, p.x)),
y: Math.max(MINI_EDGE_MARGIN, Math.min(maxY, p.y)),
};
}
function defaultMiniPos(): MiniPos {
// Resting position when nothing's been persisted yet: bottom-right
// corner, matching the old static layout (`right-4 bottom-6`).
if (typeof window === "undefined") return { x: 0, y: 0 };
return {
x: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
y: window.innerHeight - MINI_PILL_SIZE - 24,
};
}
function loadMiniPos(): MiniPos | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(MINI_POS_STORAGE_KEY);
if (!raw) return null;
const p = JSON.parse(raw);
if (typeof p?.x !== "number" || typeof p?.y !== "number") return null;
return clampMiniPos(p);
} catch {
return null;
}
}
function saveMiniPos(p: MiniPos) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(MINI_POS_STORAGE_KEY, JSON.stringify(p));
} catch {
// Storage may be unavailable (Safari private mode, quota). Drop the
// write silently — drag-and-position still works for the session.
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -295,6 +348,57 @@ export function FloatingPlayer() {
const { minimized, setMinimized, resetTimer } = const { minimized, setMinimized, resetTimer } =
useAutoMinimize(!!currentTrack); useAutoMinimize(!!currentTrack);
// Minimized pill position lives in motion values so framer's `drag`
// can mutate them directly without round-tripping through React state.
// Initial position is computed once (lazy useMemo) — the player only
// renders when a track is playing, which is always after hydration, so
// window access is safe here.
const initialMiniPos = useMemo(
() => loadMiniPos() ?? defaultMiniPos(),
[],
);
const miniX = useMotionValue(initialMiniPos.x);
const miniY = useMotionValue(initialMiniPos.y);
// Drag constraints — recomputed each render so a window resize between
// renders gets picked up the next time the user touches the pill.
const [miniBounds, setMiniBounds] = useState<{
top: number;
left: number;
right: number;
bottom: number;
}>(() => {
if (typeof window === "undefined") {
return { top: 0, left: 0, right: 0, bottom: 0 };
}
return {
top: MINI_EDGE_MARGIN,
left: MINI_EDGE_MARGIN,
right: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
bottom: window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
};
});
// Re-clamp on window resize so a saved position from a wider viewport
// doesn't strand the pill off-screen after rotation / DevTools open.
useEffect(() => {
function onResize() {
setMiniBounds({
top: MINI_EDGE_MARGIN,
left: MINI_EDGE_MARGIN,
right: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
bottom: window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
});
const clamped = clampMiniPos({ x: miniX.get(), y: miniY.get() });
miniX.set(clamped.x);
miniY.set(clamped.y);
saveMiniPos(clamped);
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [miniX, miniY]);
// Tracks whether the most-recent pointer interaction was a drag (vs a
// tap) so the click handler can suppress expand-on-drag-end.
const miniWasDraggedRef = useRef(false);
// Watch for the selection toolbar appearing/disappearing in the DOM // Watch for the selection toolbar appearing/disappearing in the DOM
useEffect(() => { useEffect(() => {
if (!currentTrack) return; if (!currentTrack) return;
@@ -422,24 +526,64 @@ export function FloatingPlayer() {
data-audio-player data-audio-player
type="button" type="button"
onClick={() => { onClick={() => {
// Suppress the click that fires at the end of every drag —
// only an actual tap should expand the player.
if (miniWasDraggedRef.current) {
miniWasDraggedRef.current = false;
return;
}
setMinimized(false); setMinimized(false);
resetTimer(); resetTimer();
}} }}
aria-label="Expand audio player" aria-label="Expand audio player"
initial={{ opacity: 0, scale: 0.5, bottom: -60 }} drag
animate={{ dragMomentum={false}
opacity: 1, dragElastic={0}
scale: 1, dragConstraints={miniBounds}
bottom: selectionToolbarVisible ? 90 : 24, onDragStart={() => {
miniWasDraggedRef.current = false;
}} }}
exit={{ opacity: 0, scale: 0.5, bottom: -60 }} onDragEnd={(_, info) => {
// Distinguish a real drag from the small jitter that a tap
// sometimes produces — only the former should suppress click.
if (
Math.abs(info.offset.x) > MINI_DRAG_THRESHOLD ||
Math.abs(info.offset.y) > MINI_DRAG_THRESHOLD
) {
miniWasDraggedRef.current = true;
}
const clamped = clampMiniPos({
x: miniX.get(),
y: miniY.get(),
});
miniX.set(clamped.x);
miniY.set(clamped.y);
saveMiniPos(clamped);
}}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ type: "spring", damping: 20, stiffness: 400 }} transition={{ type: "spring", damping: 20, stiffness: 400 }}
style={{
// Position purely via the motion-value transform so framer's
// drag controller mutates the same channel that paints it.
// `top: 0; left: 0` anchors the transform origin to the
// viewport corner; `x`/`y` then carry the pill anywhere.
position: "fixed",
top: 0,
left: 0,
x: miniX,
y: miniY,
}}
className={cn( className={cn(
"fixed z-40 right-4", "z-40",
"size-14 rounded-full", "size-14 rounded-full",
"shadow-2xl shadow-black/30", "shadow-2xl shadow-black/30",
"overflow-hidden", "overflow-hidden",
"ring-2 ring-white/20", "ring-2 ring-white/20",
// Grab affordance + touch-action: none so the browser doesn't
// intercept touch as a page scroll and starve the drag.
"cursor-grab active:cursor-grabbing touch-none",
)} )}
> >
{/* Cover image */} {/* Cover image */}
+34 -2
View File
@@ -14,11 +14,16 @@
* hidden and replaced with a "← Back to search results" button. This * hidden and replaced with a "← Back to search results" button. This
* keeps the cross-page-navigation polish that the search page relies on. * keeps the cross-page-navigation polish that the search page relies on.
* *
* Sticky headers collapse on scroll-down and re-appear on scroll-up
* (mobile-address-bar pattern). Long reading pages get back the vertical
* real estate without losing the title anchor on demand. Only applies
* when `sticky` — a non-sticky header has nothing to slide behind.
*
* Pages that don't want a sub-header (e.g. the redesigned homepage which * Pages that don't want a sub-header (e.g. the redesigned homepage which
* paints its own editorial hero) can simply not render PageHeader. * paints its own editorial hero) can simply not render PageHeader.
*/ */
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter, usePathname } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -63,12 +68,39 @@ export function PageHeader({
return false; return false;
}); });
// Scroll-direction-driven collapse. Hidden when the user is scrolling
// down past the first viewport-tenth and revealed again as soon as
// they scroll up. Near the top we always show so quick reading doesn't
// feel jumpy. Only wired up when `sticky` — that's the only case where
// the header has a fixed slot to slide behind.
const [collapsed, setCollapsed] = useState(false);
const lastScrollYRef = useRef(0);
useEffect(() => {
if (!sticky) return;
lastScrollYRef.current = window.scrollY;
function onScroll() {
const y = window.scrollY;
const delta = y - lastScrollYRef.current;
// Threshold of ~8px filters out scroll-jitter / momentum noise.
if (y < 80) setCollapsed(false);
else if (delta > 8) setCollapsed(true);
else if (delta < -8) setCollapsed(false);
lastScrollYRef.current = y;
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [sticky]);
return ( return (
<header <header
className={cn( className={cn(
"px-4 sm:px-6", "px-4 sm:px-6",
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5", bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
sticky && "sticky top-12 sm:top-14 z-20", sticky &&
"sticky top-12 sm:top-14 z-20 transition-transform duration-200 ease-out",
// -translate-y-full tucks the band fully behind the SiteHeader
// (which sits at a higher z-index), so it visually disappears.
sticky && collapsed && "-translate-y-full",
className, className,
)} )}
> >
+1 -1
View File
@@ -193,7 +193,7 @@ export function PageBookmarks() {
// highlighted line. // highlighted line.
top: m.top - 36, top: m.top - 36,
left: m.left, left: m.left,
zIndex: 40, zIndex: 5,
}} }}
className="pointer-events-none" className="pointer-events-none"
> >