diff --git a/app/glossary/glossary-client.tsx b/app/glossary/glossary-client.tsx index 070bde7e..aa542c02 100644 --- a/app/glossary/glossary-client.tsx +++ b/app/glossary/glossary-client.tsx @@ -8,7 +8,6 @@ import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { Button, buttonVariants } from "@/components/ui/button"; -import { ReadingSettings } from "@/components/reading-settings"; import { Drawer, DrawerClose, @@ -389,22 +388,17 @@ export function GlossaryClient({ <> {/* Sticky header */}
-
-
- - ← Home - -

- Glossary: - {activeEntry?.term || activeLetter} -

-
-
- -
+
+ + ← Home + +

+ Glossary: + {activeEntry?.term || activeLetter} +

diff --git a/app/layout.tsx b/app/layout.tsx index 493a596d..14484ca3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -82,7 +82,6 @@ export default function RootLayout({
{children} -
diff --git a/app/letters/filter-year-browser.tsx b/app/letters/filter-year-browser.tsx index 28ca8a1d..8e3e4027 100644 --- a/app/letters/filter-year-browser.tsx +++ b/app/letters/filter-year-browser.tsx @@ -4,7 +4,6 @@ import { useState, useMemo, useRef } from "react"; import Link from "next/link"; import { Card, CardContent } from "@/components/ui/card"; import { buttonVariants } from "@/components/ui/button"; -import { ReadingSettings } from "@/components/reading-settings"; import { cn } from "@/lib/utils"; import { ChevronLeft, ChevronRight, Menu } from "lucide-react"; import { @@ -451,29 +450,24 @@ export function FilterYearBrowser({ <> {/* Header */}
-
-
- - ← {backLabel} - -

- - {titlePrefix} {filterName}:{" "} - - {activeYearLabel} -

-

- {activeGroup?.count ?? 0}{" "} - {(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} in{" "} - {activeYearLabel} · {totalCount} total -

-
-
- -
+
+ + ← {backLabel} + +

+ + {titlePrefix} {filterName}:{" "} + + {activeYearLabel} +

+

+ {activeGroup?.count ?? 0}{" "} + {(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} in{" "} + {activeYearLabel} · {totalCount} total +

diff --git a/app/letters/year/[year]/year-month-browser.tsx b/app/letters/year/[year]/year-month-browser.tsx index 26bb3640..74ef12f5 100644 --- a/app/letters/year/[year]/year-month-browser.tsx +++ b/app/letters/year/[year]/year-month-browser.tsx @@ -4,7 +4,6 @@ import { useState, useMemo, useRef } from "react"; import Link from "next/link"; import { Card, CardContent } from "@/components/ui/card"; import { buttonVariants } from "@/components/ui/button"; -import { ReadingSettings } from "@/components/reading-settings"; import { cn } from "@/lib/utils"; import { ChevronLeft, ChevronRight, Menu } from "lucide-react"; import { @@ -356,27 +355,22 @@ export function YearMonthBrowser({ <> {/* Header */}
-
-
- - ← Letters by Year - -

- Letters: - {activeLabel} {year} -

-

- {activeGroup?.count ?? 0}{" "} - {(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} ·{" "} - {totalCount} total in {year} -

-
-
- -
+
+ + ← Letters by Year + +

+ Letters: + {activeLabel} {year} +

+

+ {activeGroup?.count ?? 0}{" "} + {(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} ·{" "} + {totalCount} total in {year} +

diff --git a/app/transcriptions/page.tsx b/app/transcriptions/page.tsx index b12b4628..05740832 100644 --- a/app/transcriptions/page.tsx +++ b/app/transcriptions/page.tsx @@ -44,8 +44,8 @@ export default async function TranscriptionsPage({ Transcriptions

- {totalCount.toLocaleString()} lectures & conversations of Śrīla - Prabhupāda (1966–1977) + {totalCount.toLocaleString()} lectures & conversations of + Śrīla Prabhupāda (1966–1977)

diff --git a/app/transcriptions/type/[type]/type-browser.tsx b/app/transcriptions/type/[type]/type-browser.tsx index af3737e3..39e303df 100644 --- a/app/transcriptions/type/[type]/type-browser.tsx +++ b/app/transcriptions/type/[type]/type-browser.tsx @@ -5,7 +5,6 @@ import Link from "next/link"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; -import { ReadingSettings } from "@/components/reading-settings"; import { cn } from "@/lib/utils"; import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react"; import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button"; @@ -477,29 +476,24 @@ export function TranscriptionTypeBrowser({ <> {/* Header */}
-
-
- - ← Transcriptions by Type - -

- {typeLabel}: - {activeYearLabel} -

-

- {activeGroup?.count ?? 0}{" "} - {(activeGroup?.count ?? 0) === 1 - ? "transcription" - : "transcriptions"}{" "} - in {activeYearLabel} · {totalCount} total -

-
-
- -
+
+ + ← Transcriptions by Type + +

+ {typeLabel}: + {activeYearLabel} +

+

+ {activeGroup?.count ?? 0}{" "} + {(activeGroup?.count ?? 0) === 1 + ? "transcription" + : "transcriptions"}{" "} + in {activeYearLabel} · {totalCount} total +

diff --git a/components/audio-player/floating-player.tsx b/components/audio-player/floating-player.tsx index b277fc8c..c76300c3 100644 --- a/components/audio-player/floating-player.tsx +++ b/components/audio-player/floating-player.tsx @@ -1,12 +1,12 @@ "use client"; -import { useRef, useCallback, useEffect, useState } from "react"; +import { useRef, useCallback, useEffect, useMemo, useState } from "react"; import { useAudioPlayer } from "./audio-context"; import { AudioWaveAnimation } from "./audio-wave"; import { cn } from "@/lib/utils"; import Link from "next/link"; import Image from "next/image"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion, AnimatePresence, useMotionValue } from "framer-motion"; import { Play, Pause, @@ -84,6 +84,59 @@ function useAutoMinimize(hasTrack: boolean) { 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 // --------------------------------------------------------------------------- @@ -295,6 +348,57 @@ export function FloatingPlayer() { const { minimized, setMinimized, resetTimer } = 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 useEffect(() => { if (!currentTrack) return; @@ -422,24 +526,64 @@ export function FloatingPlayer() { data-audio-player type="button" 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); resetTimer(); }} aria-label="Expand audio player" - initial={{ opacity: 0, scale: 0.5, bottom: -60 }} - animate={{ - opacity: 1, - scale: 1, - bottom: selectionToolbarVisible ? 90 : 24, + drag + dragMomentum={false} + dragElastic={0} + dragConstraints={miniBounds} + 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 }} + 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( - "fixed z-40 right-4", + "z-40", "size-14 rounded-full", "shadow-2xl shadow-black/30", "overflow-hidden", "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 */} diff --git a/components/page-header.tsx b/components/page-header.tsx index 7115b736..e8f7bf3b 100644 --- a/components/page-header.tsx +++ b/components/page-header.tsx @@ -14,11 +14,16 @@ * hidden and replaced with a "← Back to search results" button. This * 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 * 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 { cn } from "@/lib/utils"; @@ -63,12 +68,39 @@ export function PageHeader({ 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 (
diff --git a/components/social/page-bookmarks.tsx b/components/social/page-bookmarks.tsx index 06c29c54..04e0e7f1 100644 --- a/components/social/page-bookmarks.tsx +++ b/components/social/page-bookmarks.tsx @@ -193,7 +193,7 @@ export function PageBookmarks() { // highlighted line. top: m.top - 36, left: m.left, - zIndex: 40, + zIndex: 5, }} className="pointer-events-none" >